mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 21:54:58 +08:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38bac36960 | |||
| 2a6980417b | |||
| 6a9a0a7a7e | |||
| fcb090dd15 | |||
| ce4f0c02cb | |||
| 4ca4ad6bca | |||
| 964ead5d0d | |||
| 2c90ae3509 | |||
| ea7169abc0 | |||
| 120ac92e77 | |||
| a1e7961af3 | |||
| dc79e3a3da | |||
| 7adc38eb53 | |||
| 3f28648931 | |||
| 90d530fa7a | |||
| 5cd2e9fb88 | |||
| 8bf6ce2c25 | |||
| c41f3dcccb | |||
| 1caa240d6c | |||
| 7ffd3ba165 | |||
| ad131486d2 | |||
| 15c6e3aec8 | |||
| 6783a8a183 |
+4
-3
@@ -32,7 +32,10 @@
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.0.5"
|
||||
"vitest": "^2.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -56,7 +59,6 @@
|
||||
"@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",
|
||||
@@ -76,7 +78,6 @@
|
||||
"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"
|
||||
|
||||
Generated
+580
-257
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -7,7 +7,14 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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_from_db(&app_state) {
|
||||
if let Err(err) = ProviderService::sync_current_to_live(&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_from_db(&app_state)?;
|
||||
ProviderService::sync_current_to_live(&app_state)?;
|
||||
Ok::<_, AppError>(json!({
|
||||
"success": true,
|
||||
"message": "Live configuration synchronized"
|
||||
|
||||
@@ -122,62 +122,98 @@ 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 is_current: bool = tx
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
||||
let existing: Option<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),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
.ok();
|
||||
|
||||
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()))?;
|
||||
let is_update = existing.is_some();
|
||||
let is_current = existing.unwrap_or(false);
|
||||
|
||||
// 同步 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 {
|
||||
if is_update {
|
||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||
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],
|
||||
"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,
|
||||
],
|
||||
)
|
||||
.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()))?;
|
||||
|
||||
@@ -153,12 +153,14 @@ 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)",
|
||||
@@ -174,9 +176,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")?;
|
||||
|
||||
@@ -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",
|
||||
"enabled",
|
||||
"BOOLEAN NOT NULL DEFAULT 1",
|
||||
"branch",
|
||||
"TEXT NOT NULL DEFAULT 'main'",
|
||||
)?;
|
||||
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,9 +247,7 @@ 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, [])
|
||||
@@ -261,10 +259,7 @@ 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},仅允许字母、数字和下划线"
|
||||
)));
|
||||
@@ -292,7 +287,11 @@ 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, "列名")?;
|
||||
|
||||
|
||||
@@ -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,10 +168,7 @@ 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");
|
||||
@@ -181,10 +178,7 @@ 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");
|
||||
|
||||
@@ -307,8 +307,7 @@ 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");
|
||||
|
||||
@@ -49,6 +49,11 @@ 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() {
|
||||
@@ -56,12 +61,25 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
|
||||
}
|
||||
|
||||
let root = read_json_value(&path)?;
|
||||
let servers = root
|
||||
let mut servers: std::collections::HashMap<String, Value> = 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)
|
||||
}
|
||||
|
||||
|
||||
+147
-29
@@ -43,6 +43,7 @@ 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::{
|
||||
@@ -220,10 +221,11 @@ 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)?;
|
||||
let current_id = app_state
|
||||
.db
|
||||
.get_current_provider(app_type_str)?
|
||||
.unwrap_or_default();
|
||||
|
||||
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
||||
let current_id =
|
||||
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let manager = crate::provider::ProviderManager {
|
||||
providers,
|
||||
@@ -546,41 +548,68 @@ 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));
|
||||
}
|
||||
};
|
||||
|
||||
// 静默迁移:检测到旧版 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},将从现有配置导入");
|
||||
// 如果有预加载的配置,执行迁移
|
||||
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}");
|
||||
} else {
|
||||
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");
|
||||
}
|
||||
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);
|
||||
|
||||
// 检查是否需要首次导入(数据库为空)
|
||||
@@ -705,10 +734,35 @@ pub fn run() {
|
||||
// Linux 和 Windows 调试模式需要显式注册
|
||||
#[cfg(any(target_os = "linux", 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 (Linux/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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,3 +1006,67 @@ 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()
|
||||
}
|
||||
|
||||
@@ -2,5 +2,15 @@
|
||||
#![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();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -84,53 +83,6 @@ 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)?;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::settings;
|
||||
|
||||
/// Gemini authentication type enumeration
|
||||
///
|
||||
@@ -19,10 +18,6 @@ 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";
|
||||
@@ -94,159 +89,22 @@ 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.
|
||||
///
|
||||
/// # 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)]
|
||||
/// This is a convenience wrapper around `detect_gemini_auth_type`.
|
||||
pub(crate) fn is_google_official_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(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(())
|
||||
detect_gemini_auth_type(provider) == GeminiAuthType::GoogleOfficial
|
||||
}
|
||||
|
||||
/// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode)
|
||||
///
|
||||
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
|
||||
///
|
||||
/// # Why write to two settings.json files
|
||||
/// # What it does
|
||||
///
|
||||
/// Same as `ensure_packycode_security_flag`, need to configure both app-level and client-level settings.
|
||||
/// Writes to **`~/.gemini/settings.json`** (Gemini client config).
|
||||
///
|
||||
/// # Value set
|
||||
///
|
||||
@@ -276,9 +134,6 @@ 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()?;
|
||||
|
||||
@@ -15,8 +15,7 @@ use crate::services::mcp::McpService;
|
||||
use crate::store::AppState;
|
||||
|
||||
use super::gemini_auth::{
|
||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
|
||||
GeminiAuthType,
|
||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
|
||||
};
|
||||
use super::normalize_claude_models_in_value;
|
||||
|
||||
@@ -101,12 +100,13 @@ 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,51 +114,36 @@ 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 => {
|
||||
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)?;
|
||||
}
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
write_gemini_live(provider)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider from database to live configuration
|
||||
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
|
||||
/// Sync current provider to live configuration
|
||||
///
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
let current_id = match state.db.get_current_provider(app_type.as_str())? {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
// 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 providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_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
|
||||
@@ -328,28 +313,44 @@ 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 (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 {
|
||||
// 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() {
|
||||
return Err(AppError::localized(
|
||||
"gemini.validation.invalid_config",
|
||||
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||
"Gemini config invalid: config must be an object or null",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// config is null: don't modify existing settings.json (preserve mcpServers etc.)
|
||||
}
|
||||
|
||||
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)?);
|
||||
}
|
||||
// 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)?);
|
||||
}
|
||||
|
||||
match auth_type {
|
||||
@@ -371,14 +372,17 @@ 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 => ensure_packycode_security_flag(provider)?,
|
||||
GeminiAuthType::Generic => {}
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
crate::gemini_config::write_packycode_settings()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -22,16 +22,12 @@ 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_from_db};
|
||||
pub use live::{import_default_config, read_live_settings, sync_current_to_live};
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -98,10 +94,12 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
/// Get current provider ID
|
||||
///
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_current_provider(app_type.as_str())
|
||||
crate::settings::get_effective_current_provider(&state.db, &app_type)
|
||||
.map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -139,9 +137,10 @@ impl ProviderService {
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// 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());
|
||||
// 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());
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
@@ -156,13 +155,19 @@ impl ProviderService {
|
||||
}
|
||||
|
||||
/// Delete a provider
|
||||
///
|
||||
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||
if current.as_deref() == Some(id) {
|
||||
// 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) {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前正在使用的供应商".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
@@ -171,9 +176,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. Set new current provider
|
||||
/// 4. Write target provider config to live files
|
||||
/// 5. Gemini additional security flag handling
|
||||
/// 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
|
||||
/// 6. Sync MCP configuration
|
||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// Check if provider exists
|
||||
@@ -183,7 +188,10 @@ impl ProviderService {
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||
|
||||
// Backfill: Backfill current live config to current provider
|
||||
if let Some(current_id) = state.db.get_current_provider(app_type.as_str())? {
|
||||
// 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 current_id != id {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
@@ -196,31 +204,24 @@ impl ProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
// Set current
|
||||
// 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)
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
|
||||
// Sync to live
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
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 from database to live configuration (re-export)
|
||||
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
|
||||
sync_current_from_db(state)
|
||||
/// Sync current provider to live configuration (re-export)
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
|
||||
@@ -168,9 +168,7 @@ 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}"
|
||||
),
|
||||
|
||||
+161
-84
@@ -214,56 +214,8 @@ impl SkillService {
|
||||
temp_dir.clone()
|
||||
};
|
||||
|
||||
// 遍历目标目录
|
||||
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),
|
||||
}
|
||||
}
|
||||
// 递归扫描目录查找所有技能
|
||||
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
@@ -271,6 +223,90 @@ 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)?;
|
||||
@@ -302,25 +338,18 @@ impl SkillService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&self.install_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
// 收集所有本地技能
|
||||
let mut local_skills = Vec::new();
|
||||
self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// 处理找到的本地技能
|
||||
for local_skill in local_skills {
|
||||
let directory = &local_skill.directory;
|
||||
|
||||
// 安全地获取目录名
|
||||
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;
|
||||
@@ -329,23 +358,69 @@ impl SkillService {
|
||||
|
||||
// 添加本地独有的技能(仅当在仓库中未找到时)
|
||||
if !found {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,11 +428,13 @@ impl SkillService {
|
||||
}
|
||||
|
||||
/// 去重技能列表
|
||||
/// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能
|
||||
fn deduplicate_skills(skills: &mut Vec<Skill>) {
|
||||
let mut seen = HashMap::new();
|
||||
skills.retain(|skill| {
|
||||
let key = skill.directory.to_lowercase();
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(key) {
|
||||
// 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存
|
||||
let unique_key = skill.key.to_lowercase();
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
||||
e.insert(true);
|
||||
true
|
||||
} else {
|
||||
|
||||
+100
-118
@@ -1,13 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 自定义端点配置
|
||||
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomEndpoint {
|
||||
@@ -17,24 +16,14 @@ pub struct CustomEndpoint {
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// 应用设置结构,允许覆盖默认配置目录
|
||||
/// 应用设置结构
|
||||
///
|
||||
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
|
||||
/// 这确保了云同步场景下多设备可以独立运作。
|
||||
#[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")]
|
||||
@@ -42,25 +31,30 @@ 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 language: Option<String>,
|
||||
/// 是否开机自启
|
||||
#[serde(default)]
|
||||
pub launch_on_startup: bool,
|
||||
pub current_provider_claude: Option<String>,
|
||||
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
pub current_provider_codex: Option<String>,
|
||||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_gemini: Option<String>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
@@ -77,14 +71,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,
|
||||
language: None,
|
||||
launch_on_startup: false,
|
||||
security: None,
|
||||
custom_endpoints_claude: HashMap::new(),
|
||||
custom_endpoints_codex: HashMap::new(),
|
||||
current_provider_claude: None,
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,60 +163,7 @@ 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(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)
|
||||
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
|
||||
}
|
||||
|
||||
fn resolve_override_path(raw: &str) -> PathBuf {
|
||||
@@ -249,47 +190,22 @@ pub fn get_settings() -> AppSettings {
|
||||
|
||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||||
new_settings.normalize_paths();
|
||||
if let Some(db) = SETTINGS_DB.get() {
|
||||
save_to_db(db, &new_settings)?;
|
||||
} else {
|
||||
save_settings_file(&new_settings)?;
|
||||
}
|
||||
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 = load_initial_settings();
|
||||
let fresh_settings = AppSettings::load_from_file();
|
||||
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
|
||||
@@ -313,3 +229,69 @@ 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. 如果不存在则清理本地 settings,fallback 到数据库的 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())
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ 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() {
|
||||
@@ -843,21 +845,22 @@ fn sync_gemini_packycode_sets_security_selected_type() {
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing gemini live should succeed");
|
||||
|
||||
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
settings_path.exists(),
|
||||
"settings.json should exist at {}",
|
||||
settings_path.display()
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings.json should exist at {}",
|
||||
gemini_settings.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");
|
||||
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");
|
||||
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"
|
||||
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -893,22 +896,7 @@ fn sync_gemini_google_official_sets_oauth_security() {
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing google official gemini should succeed");
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
@@ -923,7 +911,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 also record oauth-personal"
|
||||
"Gemini settings should record oauth-personal for Google Official"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,13 @@ 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");
|
||||
@@ -87,7 +91,9 @@ 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(),
|
||||
@@ -125,8 +131,7 @@ 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");
|
||||
@@ -168,8 +173,7 @@ 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"
|
||||
@@ -224,11 +228,8 @@ 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"
|
||||
|
||||
@@ -7,8 +7,8 @@ use cc_switch_lib::{
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||||
use std::collections::HashMap;
|
||||
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||||
|
||||
#[test]
|
||||
fn switch_provider_updates_codex_live_and_state() {
|
||||
@@ -104,16 +104,22 @@ 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")
|
||||
@@ -165,7 +171,9 @@ 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}"
|
||||
);
|
||||
}
|
||||
@@ -241,11 +249,19 @@ 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
|
||||
@@ -258,9 +274,7 @@ 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
|
||||
@@ -283,7 +297,9 @@ 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(),
|
||||
@@ -328,7 +344,9 @@ 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 配置写入失败,状态应该回滚
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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()
|
||||
@@ -69,7 +71,10 @@ 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 {
|
||||
@@ -111,16 +116,22 @@ 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")
|
||||
@@ -185,15 +196,16 @@ fn switch_packycode_gemini_updates_security_selected_type() {
|
||||
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
|
||||
.expect("switching to PackyCode Gemini should succeed");
|
||||
|
||||
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let settings_path = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
settings_path.exists(),
|
||||
"settings.json should exist at {}",
|
||||
"Gemini settings.json should exist at {}",
|
||||
settings_path.display()
|
||||
);
|
||||
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||||
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
|
||||
|
||||
assert_eq!(
|
||||
value
|
||||
@@ -239,15 +251,16 @@ 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");
|
||||
|
||||
let settings_path = home.join(".cc-switch").join("settings.json");
|
||||
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let settings_path = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
settings_path.exists(),
|
||||
"settings.json should exist at {}",
|
||||
"Gemini settings.json should exist at {}",
|
||||
settings_path.display()
|
||||
);
|
||||
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
|
||||
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("parse settings.json after switch");
|
||||
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
|
||||
|
||||
assert_eq!(
|
||||
value
|
||||
@@ -292,23 +305,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
|
||||
ProviderService::switch(&state, AppType::Gemini, "google-official")
|
||||
.expect("switching to Google official Gemini should succeed");
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
@@ -324,7 +321,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 also reflect oauth-personal"
|
||||
"Gemini settings json should reflect oauth-personal for Google Official"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -399,11 +396,19 @@ 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")
|
||||
@@ -523,7 +528,9 @@ 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"),
|
||||
@@ -581,7 +588,9 @@ 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"),
|
||||
@@ -593,6 +602,10 @@ 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
|
||||
@@ -618,15 +631,18 @@ 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:?}"),
|
||||
|
||||
+33
-30
@@ -6,7 +6,7 @@ import {
|
||||
Plus,
|
||||
Settings,
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
// Bot, // TODO: Agents 功能开发中,暂时不需要
|
||||
Book,
|
||||
Wrench,
|
||||
Server,
|
||||
@@ -24,6 +24,7 @@ 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";
|
||||
@@ -404,7 +405,6 @@ function App() {
|
||||
>
|
||||
CC Switch
|
||||
</a>
|
||||
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -470,39 +470,24 @@ 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("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
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")}
|
||||
>
|
||||
<Book className="h-4 w-4" />
|
||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||
</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"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title="MCP"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
</Button>
|
||||
{isClaudeApp && (
|
||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||
{/* {isClaudeApp && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -512,7 +497,25 @@ function App() {
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title="MCP"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -40,8 +40,12 @@ export function ConfirmDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader className="space-y-3">
|
||||
<DialogContent
|
||||
className="max-w-sm"
|
||||
zIndex="alert"
|
||||
overlayClassName="bg-background/80"
|
||||
>
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
{title}
|
||||
@@ -50,7 +54,7 @@ export function ConfirmDialog({
|
||||
{message}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelText || t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -140,15 +140,24 @@ 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 && !map.has(sanitized)) {
|
||||
if (!sanitized) return;
|
||||
|
||||
const existing = map.get(sanitized);
|
||||
if (existing) {
|
||||
// URL 已存在,更新 isCustom 为 true(因为它在数据库中)
|
||||
existing.isCustom = true;
|
||||
} else {
|
||||
// URL 不存在,添加新条目
|
||||
map.set(sanitized, {
|
||||
id: randomId(),
|
||||
url: sanitized,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -326,11 +327,11 @@ export function ProviderForm({
|
||||
configError: geminiConfigError,
|
||||
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
|
||||
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
|
||||
handleGeminiModelChange: originalHandleGeminiModelChange,
|
||||
handleGeminiEnvChange,
|
||||
handleGeminiConfigChange,
|
||||
resetGeminiConfig,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
} = useGeminiConfigState({
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
});
|
||||
@@ -368,6 +369,22 @@ 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,
|
||||
@@ -388,17 +405,82 @@ export function ProviderForm({
|
||||
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||
const validation = validateTemplateValues();
|
||||
if (!validation.isValid && validation.missingField) {
|
||||
form.setError("settingsConfig", {
|
||||
type: "manual",
|
||||
message: t("providerForm.fillParameter", {
|
||||
toast.error(
|
||||
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
|
||||
@@ -616,6 +698,8 @@ export function ProviderForm({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify({ auth, config }, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
iconColor: preset.iconColor ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -633,6 +717,8 @@ export function ProviderForm({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
iconColor: preset.iconColor ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -647,6 +733,8 @@ export function ProviderForm({
|
||||
name: preset.name,
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify(config, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
iconColor: preset.iconColor ?? "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -758,19 +846,7 @@ export function ProviderForm({
|
||||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||||
shouldShowModelField={true}
|
||||
model={geminiModel}
|
||||
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);
|
||||
}}
|
||||
onModelChange={handleGeminiModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -22,18 +22,32 @@ 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[] = [];
|
||||
if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") {
|
||||
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
|
||||
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.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}`);
|
||||
|
||||
// 再添加其他自定义 key(保留用户添加的环境变量)
|
||||
for (const [key, value] of Object.entries(envObj)) {
|
||||
if (!addedKeys.has(key) && typeof value === "string") {
|
||||
lines.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
},
|
||||
[],
|
||||
@@ -164,6 +178,20 @@ 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) => {
|
||||
@@ -223,6 +251,7 @@ export function useGeminiConfigState({
|
||||
setGeminiConfig,
|
||||
handleGeminiApiKeyChange,
|
||||
handleGeminiBaseUrlChange,
|
||||
handleGeminiModelChange,
|
||||
handleGeminiEnvChange,
|
||||
handleGeminiConfigChange,
|
||||
resetGeminiConfig,
|
||||
|
||||
@@ -76,6 +76,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "deepseek",
|
||||
iconColor: "#1E88E5",
|
||||
},
|
||||
{
|
||||
name: "Zhipu GLM",
|
||||
@@ -94,6 +96,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
category: "cn_official",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
},
|
||||
{
|
||||
name: "Z.ai GLM",
|
||||
@@ -112,6 +116,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
category: "cn_official",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "zhipu", // 促销信息 i18n key
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
},
|
||||
{
|
||||
name: "Qwen Coder",
|
||||
@@ -128,6 +134,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "qwen",
|
||||
iconColor: "#FF6A00",
|
||||
},
|
||||
{
|
||||
name: "Kimi k2",
|
||||
@@ -143,6 +151,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
{
|
||||
name: "Kimi For Coding",
|
||||
@@ -158,6 +168,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "kimi",
|
||||
iconColor: "#6366F1",
|
||||
},
|
||||
{
|
||||
name: "ModelScope",
|
||||
@@ -173,6 +185,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "alibaba",
|
||||
iconColor: "#FF6A00",
|
||||
},
|
||||
{
|
||||
name: "KAT-Coder",
|
||||
@@ -220,7 +234,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "MiniMax",
|
||||
websiteUrl: "https://platform.minimaxi.com",
|
||||
apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information",
|
||||
apiKeyUrl: "https://platform.minimax.io/subscribe/coding-plan",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.minimaxi.com/anthropic",
|
||||
@@ -234,6 +248,14 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "minimax",
|
||||
theme: {
|
||||
backgroundColor: "#f64551",
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
icon: "minimax",
|
||||
iconColor: "#FF6B6B",
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
@@ -251,6 +273,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "BaiLing",
|
||||
@@ -266,6 +290,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "alibaba",
|
||||
iconColor: "#FF6A00",
|
||||
},
|
||||
{
|
||||
name: "AiHubMix",
|
||||
@@ -290,11 +316,11 @@ export const providerPresets: ProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://www.dmxapi.cn",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
||||
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
|
||||
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
|
||||
category: "aggregator",
|
||||
},
|
||||
{
|
||||
@@ -315,5 +341,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||
icon: "packycode",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface CodexProviderPreset {
|
||||
endpointCandidates?: string[];
|
||||
// 新增:视觉主题配置
|
||||
theme?: PresetTheme;
|
||||
// 图标配置
|
||||
icon?: string; // 图标名称
|
||||
iconColor?: string; // 图标颜色
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +74,8 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
backgroundColor: "#1F2937", // gray-800
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
icon: "openai",
|
||||
iconColor: "#00A67E",
|
||||
},
|
||||
{
|
||||
name: "Azure OpenAI",
|
||||
@@ -97,6 +102,8 @@ requires_openai_auth = true`,
|
||||
backgroundColor: "#0078D4",
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
icon: "azure",
|
||||
iconColor: "#0078D4",
|
||||
},
|
||||
{
|
||||
name: "AiHubMix",
|
||||
@@ -142,5 +149,6 @@ requires_openai_auth = true`,
|
||||
],
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||
icon: "packycode",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface GeminiProviderPreset {
|
||||
partnerPromotionKey?: string;
|
||||
endpointCandidates?: string[];
|
||||
theme?: GeminiPresetTheme;
|
||||
// 图标配置
|
||||
icon?: string; // 图标名称
|
||||
iconColor?: string; // 图标颜色
|
||||
}
|
||||
|
||||
export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
@@ -43,6 +46,8 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
backgroundColor: "#4285F4",
|
||||
textColor: "#FFFFFF",
|
||||
},
|
||||
icon: "gemini",
|
||||
iconColor: "#4285F4",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
@@ -64,6 +69,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
"https://api-slb.packyapi.com",
|
||||
"https://www.packyapi.com",
|
||||
],
|
||||
icon: "packycode",
|
||||
},
|
||||
{
|
||||
name: "自定义",
|
||||
|
||||
@@ -261,7 +261,8 @@
|
||||
"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"
|
||||
"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!)"
|
||||
},
|
||||
"parameterConfig": "Parameter Config - {{name}} *",
|
||||
"mainModel": "Main Model (optional)",
|
||||
@@ -275,6 +276,8 @@
|
||||
"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",
|
||||
|
||||
@@ -261,7 +261,8 @@
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠"
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
|
||||
"minimax": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
|
||||
},
|
||||
"parameterConfig": "参数配置 - {{name}} *",
|
||||
"mainModel": "主模型 (可选)",
|
||||
@@ -275,6 +276,8 @@
|
||||
"fillConfigContent": "请填写配置内容",
|
||||
"fillParameter": "请填写 {{label}}",
|
||||
"fillTemplateValue": "请填写 {{label}}",
|
||||
"endpointRequired": "非官方供应商请填写 API 端点",
|
||||
"apiKeyRequired": "非官方供应商请填写 API Key",
|
||||
"configJsonError": "配置JSON格式错误,请检查语法",
|
||||
"authJsonRequired": "auth.json 必须是 JSON 对象",
|
||||
"authJsonError": "auth.json 格式错误,请检查JSON语法",
|
||||
|
||||
+4
-5
@@ -1,8 +1,7 @@
|
||||
/* 引入 Tailwind v4 内建样式与工具 */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 覆盖 Tailwind v4 默认的 dark 变体为"类选择器"模式 */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
/* Tailwind CSS v3 指令 */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* shadcn/ui 主题变量 - 蓝色主题 */
|
||||
@layer base {
|
||||
|
||||
@@ -95,6 +95,13 @@ 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),
|
||||
@@ -106,7 +113,8 @@ export const useUsageQuery = (
|
||||
refetchIntervalInBackground: true, // 后台也继续定时查询
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 0, // 不使用缓存策略,确保 refetchInterval 准确执行
|
||||
staleTime, // 使用动态计算的缓存时间
|
||||
gcTime: 10 * 60 * 1000, // 缓存保留 10 分钟(组件卸载后)
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -36,7 +36,7 @@ function parseJsonError(error: unknown): string {
|
||||
}
|
||||
|
||||
export const providerSchema = z.object({
|
||||
name: z.string().min(1, "请填写供应商名称"),
|
||||
name: z.string(), // 必填校验移至 handleSubmit 中用 toast 提示
|
||||
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
|
||||
notes: z.string().optional(),
|
||||
settingsConfig: z
|
||||
|
||||
@@ -8,14 +8,22 @@ 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(),
|
||||
language: z.enum(["en", "zh"]).optional(),
|
||||
customEndpointsClaude: z.record(z.string(), z.unknown()).optional(),
|
||||
customEndpointsCodex: z.record(z.string(), z.unknown()).optional(),
|
||||
geminiConfigDir: directorySchema.nullable().optional(),
|
||||
|
||||
// 当前供应商 ID(设备级)
|
||||
currentProviderClaude: z.string().optional(),
|
||||
currentProviderCodex: z.string().optional(),
|
||||
currentProviderGemini: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
||||
|
||||
+16
-14
@@ -97,33 +97,35 @@ 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;
|
||||
// 首选语言(可选,默认中文)
|
||||
language?: "en" | "zh";
|
||||
// 是否开机自启
|
||||
launchOnStartup?: boolean;
|
||||
// Claude 自定义端点列表
|
||||
customEndpointsClaude?: Record<string, CustomEndpoint>;
|
||||
// Codex 自定义端点列表
|
||||
customEndpointsCodex?: Record<string, CustomEndpoint>;
|
||||
// 安全设置(兼容未来扩展)
|
||||
security?: {
|
||||
auth?: {
|
||||
selectedType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ===== 当前供应商 ID(设备级)=====
|
||||
// 当前 Claude 供应商 ID(优先于数据库 is_current)
|
||||
currentProviderClaude?: string;
|
||||
// 当前 Codex 供应商 ID(优先于数据库 is_current)
|
||||
currentProviderCodex?: string;
|
||||
// 当前 Gemini 供应商 ID(优先于数据库 is_current)
|
||||
currentProviderGemini?: string;
|
||||
}
|
||||
|
||||
// MCP 服务器连接参数(宽松:允许扩展字段)
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
"./src/index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "selector",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
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(), tailwindcss()],
|
||||
plugins: [react()],
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
|
||||
Reference in New Issue
Block a user