diff --git a/deplink.html b/deplink.html new file mode 100644 index 0000000..5fe2c5d --- /dev/null +++ b/deplink.html @@ -0,0 +1,551 @@ + + + + + + CC Switch 深链接测试 + + + +
+
+

🔗 CC Switch 深链接测试

+

点击下方链接测试深链接导入功能

+
+ +
+ +
+

Claude Code 供应商

+ + + + +
+ + +
+

Codex 供应商

+ + + + +
+ + +
+

Gemini 供应商

+ + + + +
+ + +
+

⚠️ 使用注意事项

+
    +
  • 首次点击:浏览器会询问是否允许打开 CC Switch,请点击"允许"或"打开"
  • +
  • macOS 用户:可能需要在"系统设置" → "隐私与安全性"中允许应用
  • +
  • 测试 API Key:示例中的 API Key 仅用于测试格式,无法实际使用
  • +
  • 导入确认:点击链接后会弹出确认对话框,API Key 会被掩码显示(前4位+****)
  • +
  • 编辑配置:导入后可以在 CC Switch 中随时编辑或删除配置
  • +
+
+ + +
+

🛠️ 深链接生成器

+

填写下方表单,生成您自己的深链接

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+
+
+ + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0663cc8..12a3653 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -613,6 +613,7 @@ dependencies = [ "serial_test", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", @@ -625,6 +626,7 @@ dependencies = [ "tokio", "toml 0.8.2", "toml_edit 0.22.27", + "url", "winreg 0.52.0", "zip 2.4.2", ] @@ -711,6 +713,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -821,6 +843,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -1057,6 +1085,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1745,6 +1782,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -1830,6 +1873,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2901,6 +2950,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3756,6 +3815,16 @@ dependencies = [ "cc", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.38.0" @@ -4519,6 +4588,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", @@ -4634,6 +4704,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.4.0" @@ -4988,6 +5079,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -5917,6 +6017,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ea5f9ca..93502dc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,13 +26,14 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" chrono = { version = "0.4", features = ["serde"] } -tauri = { version = "2.8.2", features = ["tray-icon"] } +tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] } tauri-plugin-log = "2" tauri-plugin-opener = "2" tauri-plugin-process = "2" tauri-plugin-updater = "2" tauri-plugin-dialog = "2" tauri-plugin-store = "2" +tauri-plugin-deep-link = "2" dirs = "5.0" toml = "0.8" toml_edit = "0.22" @@ -46,6 +47,7 @@ anyhow = "1.0" zip = "2.2" serde_yaml = "0.9" tempfile = "3" +url = "2.5" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..93db133 --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,19 @@ + + + + + + CFBundleURLTypes + + + CFBundleURLName + CC Switch Deep Link + CFBundleURLSchemes + + ccswitch + + + + + + diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index b5b5fb0..2f81def 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -184,12 +184,12 @@ pub async fn get_common_config_snippet( use crate::app_config::AppType; use std::str::FromStr; - let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?; + let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let guard = state .config .read() - .map_err(|e| format!("读取配置锁失败: {}", e))?; + .map_err(|e| format!("读取配置锁失败: {e}"))?; Ok(guard.common_config_snippets.get(&app).cloned()) } @@ -204,12 +204,12 @@ pub async fn set_common_config_snippet( use crate::app_config::AppType; use std::str::FromStr; - let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?; + let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let mut guard = state .config .write() - .map_err(|e| format!("写入配置锁失败: {}", e))?; + .map_err(|e| format!("写入配置锁失败: {e}"))?; // 验证格式(根据应用类型) if !snippet.trim().is_empty() { @@ -217,7 +217,7 @@ pub async fn set_common_config_snippet( AppType::Claude | AppType::Gemini => { // 验证 JSON 格式 serde_json::from_str::(&snippet) - .map_err(|e| format!("无效的 JSON 格式: {}", e))?; + .map_err(|e| format!("无效的 JSON 格式: {e}"))?; } AppType::Codex => { // TOML 格式暂不验证(或可使用 toml crate) diff --git a/src-tauri/src/commands/deeplink.rs b/src-tauri/src/commands/deeplink.rs new file mode 100644 index 0000000..663231f --- /dev/null +++ b/src-tauri/src/commands/deeplink.rs @@ -0,0 +1,29 @@ +use crate::deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; +use crate::store::AppState; +use tauri::State; + +/// Parse a deep link URL and return the parsed request for frontend confirmation +#[tauri::command] +pub fn parse_deeplink(url: String) -> Result { + log::info!("Parsing deep link URL: {url}"); + parse_deeplink_url(&url).map_err(|e| e.to_string()) +} + +/// Import a provider from a deep link request (after user confirmation) +#[tauri::command] +pub fn import_from_deeplink( + state: State, + request: DeepLinkImportRequest, +) -> Result { + log::info!( + "Importing provider from deep link: {} for app {}", + request.name, + request.app + ); + + let provider_id = import_provider_from_deeplink(&state, request).map_err(|e| e.to_string())?; + + log::info!("Successfully imported provider with ID: {provider_id}"); + + Ok(provider_id) +} diff --git a/src-tauri/src/commands/env.rs b/src-tauri/src/commands/env.rs index e1b31a8..8cf9df9 100644 --- a/src-tauri/src/commands/env.rs +++ b/src-tauri/src/commands/env.rs @@ -1,5 +1,7 @@ use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict}; -use crate::services::env_manager::{delete_env_vars as delete_vars, restore_from_backup, BackupInfo}; +use crate::services::env_manager::{ + delete_env_vars as delete_vars, restore_from_backup, BackupInfo, +}; /// Check environment variable conflicts for a specific app #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 837d9af..a98da44 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod config; +mod deeplink; mod env; mod import_export; mod mcp; @@ -12,6 +13,7 @@ mod settings; pub mod skill; pub use config::*; +pub use deeplink::*; pub use env::*; pub use import_export::*; pub use mcp::*; diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs new file mode 100644 index 0000000..b6d062f --- /dev/null +++ b/src-tauri/src/deeplink.rs @@ -0,0 +1,457 @@ +/// Deep link import functionality for CC Switch +/// +/// This module implements the ccswitch:// protocol for importing provider configurations +/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design. +use crate::error::AppError; +use crate::provider::Provider; +use crate::services::ProviderService; +use crate::store::AppState; +use crate::AppType; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::str::FromStr; +use url::Url; + +/// Deep link import request model +/// Represents a parsed ccswitch:// URL ready for processing +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeepLinkImportRequest { + /// Protocol version (e.g., "v1") + pub version: String, + /// Resource type to import (e.g., "provider") + pub resource: String, + /// Target application (claude/codex/gemini) + pub app: String, + /// Provider name + pub name: String, + /// Provider homepage URL + pub homepage: String, + /// API endpoint/base URL + pub endpoint: String, + /// API key + pub api_key: String, + /// Optional model name + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Optional notes/description + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// Parse a ccswitch:// URL into a DeepLinkImportRequest +/// +/// Expected format: +/// ccswitch://v1/import?resource=provider&app=claude&name=...&homepage=...&endpoint=...&apiKey=... +pub fn parse_deeplink_url(url_str: &str) -> Result { + // Parse URL + let url = Url::parse(url_str) + .map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?; + + // Validate scheme + let scheme = url.scheme(); + if scheme != "ccswitch" { + return Err(AppError::InvalidInput(format!( + "Invalid scheme: expected 'ccswitch', got '{scheme}'" + ))); + } + + // Extract version from host + let version = url + .host_str() + .ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))? + .to_string(); + + // Validate version + if version != "v1" { + return Err(AppError::InvalidInput(format!( + "Unsupported protocol version: {version}" + ))); + } + + // Extract path (should be "/import") + let path = url.path(); + if path != "/import" { + return Err(AppError::InvalidInput(format!( + "Invalid path: expected '/import', got '{path}'" + ))); + } + + // Parse query parameters + let params: HashMap = url.query_pairs().into_owned().collect(); + + // Extract and validate resource type + let resource = params + .get("resource") + .ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))? + .clone(); + + if resource != "provider" { + return Err(AppError::InvalidInput(format!( + "Unsupported resource type: {resource}" + ))); + } + + // Extract required fields + let app = params + .get("app") + .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))? + .clone(); + + // Validate app type + if app != "claude" && app != "codex" && app != "gemini" { + return Err(AppError::InvalidInput(format!( + "Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'" + ))); + } + + let name = params + .get("name") + .ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))? + .clone(); + + let homepage = params + .get("homepage") + .ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))? + .clone(); + + let endpoint = params + .get("endpoint") + .ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))? + .clone(); + + let api_key = params + .get("apiKey") + .ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))? + .clone(); + + // Validate URLs + validate_url(&homepage, "homepage")?; + validate_url(&endpoint, "endpoint")?; + + // Extract optional fields + let model = params.get("model").cloned(); + let notes = params.get("notes").cloned(); + + Ok(DeepLinkImportRequest { + version, + resource, + app, + name, + homepage, + endpoint, + api_key, + model, + notes, + }) +} + +/// Validate that a string is a valid HTTP(S) URL +fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> { + let url = Url::parse(url_str) + .map_err(|e| AppError::InvalidInput(format!("Invalid URL for '{field_name}': {e}")))?; + + let scheme = url.scheme(); + if scheme != "http" && scheme != "https" { + return Err(AppError::InvalidInput(format!( + "Invalid URL scheme for '{field_name}': must be http or https, got '{scheme}'" + ))); + } + + Ok(()) +} + +/// Import a provider from a deep link request +/// +/// This function: +/// 1. Validates the request +/// 2. Converts it to a Provider structure +/// 3. Delegates to ProviderService for actual import +pub fn import_provider_from_deeplink( + state: &AppState, + request: DeepLinkImportRequest, +) -> Result { + // Parse app type + let app_type = AppType::from_str(&request.app) + .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?; + + // Build provider configuration based on app type + let mut provider = build_provider_from_request(&app_type, &request)?; + + // Generate a unique ID for the provider using timestamp + sanitized name + // This is similar to how frontend generates IDs + let timestamp = chrono::Utc::now().timestamp_millis(); + let sanitized_name = request + .name + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') + .collect::() + .to_lowercase(); + provider.id = format!("{sanitized_name}-{timestamp}"); + + let provider_id = provider.id.clone(); + + // Use ProviderService to add the provider + ProviderService::add(state, app_type, provider)?; + + Ok(provider_id) +} + +/// Build a Provider structure from a deep link request +fn build_provider_from_request( + app_type: &AppType, + request: &DeepLinkImportRequest, +) -> Result { + use serde_json::json; + + let settings_config = match app_type { + AppType::Claude => { + // Claude configuration structure + let mut env = serde_json::Map::new(); + env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key)); + env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint)); + + // Add model if provided (use as default model) + if let Some(model) = &request.model { + env.insert("ANTHROPIC_MODEL".to_string(), json!(model)); + } + + json!({ "env": env }) + } + AppType::Codex => { + // Codex configuration structure + // For Codex, we store auth.json (JSON) and config.toml (TOML string) in settings_config。 + // + // 这里尽量与前端 `getCodexCustomTemplate` 的默认模板保持一致, + // 再根据深链接参数注入 base_url / model,避免出现“只有 base_url 行”的极简配置, + // 让通过 UI 新建和通过深链接导入的 Codex 自定义供应商行为一致。 + + // 1. 生成一个适合作为 model_provider 名的安全标识 + // 规则尽量与前端 codexProviderPresets.generateThirdPartyConfig 保持一致: + // - 转小写 + // - 非 [a-z0-9_] 统一替换为下划线 + // - 去掉首尾下划线 + // - 若结果为空,则使用 "custom" + let clean_provider_name = { + let raw: String = request.name.chars().filter(|c| !c.is_control()).collect(); + let lower = raw.to_lowercase(); + let mut key: String = lower + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '_' => c, + _ => '_', + }) + .collect(); + + // 去掉首尾下划线 + while key.starts_with('_') { + key.remove(0); + } + while key.ends_with('_') { + key.pop(); + } + + if key.is_empty() { + "custom".to_string() + } else { + key + } + }; + + // 2. 模型名称:优先使用 deeplink 中的 model,否则退回到 Codex 默认模型 + let model_name = request + .model + .as_deref() + .unwrap_or("gpt-5-codex") + .to_string(); + + // 3. 端点:与 UI 中 Base URL 处理方式保持一致,去掉结尾多余的斜杠 + let endpoint = request.endpoint.trim().trim_end_matches('/').to_string(); + + // 4. 组装 config.toml 内容 + // 使用 Rust 1.58+ 的内联格式化语法,避免 clippy::uninlined_format_args 警告 + let config_toml = format!( + r#"model_provider = "{clean_provider_name}" +model = "{model_name}" +model_reasoning_effort = "high" +disable_response_storage = true + +[model_providers.{clean_provider_name}] +name = "{clean_provider_name}" +base_url = "{endpoint}" +wire_api = "responses" +requires_openai_auth = true +"# + ); + + json!({ + "auth": { + "OPENAI_API_KEY": request.api_key, + }, + "config": config_toml + }) + } + AppType::Gemini => { + // Gemini configuration structure (.env format) + let mut env = serde_json::Map::new(); + env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key)); + env.insert( + "GOOGLE_GEMINI_BASE_URL".to_string(), + json!(request.endpoint), + ); + + // Add model if provided + if let Some(model) = &request.model { + env.insert("GEMINI_MODEL".to_string(), json!(model)); + } + + json!({ "env": env }) + } + }; + + let provider = Provider { + id: String::new(), // Will be generated by ProviderService + name: request.name.clone(), + settings_config, + website_url: Some(request.homepage.clone()), + category: None, + created_at: None, + sort_index: None, + notes: request.notes.clone(), + meta: None, + }; + + Ok(provider) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_claude_deeplink() { + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123"; + + let request = parse_deeplink_url(url).unwrap(); + + assert_eq!(request.version, "v1"); + assert_eq!(request.resource, "provider"); + assert_eq!(request.app, "claude"); + assert_eq!(request.name, "Test Provider"); + assert_eq!(request.homepage, "https://example.com"); + assert_eq!(request.endpoint, "https://api.example.com"); + assert_eq!(request.api_key, "sk-test-123"); + } + + #[test] + fn test_parse_deeplink_with_notes() { + let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes"; + + let request = parse_deeplink_url(url).unwrap(); + + assert_eq!(request.notes, Some("Test notes".to_string())); + } + + #[test] + fn test_parse_invalid_scheme() { + let url = "https://v1/import?resource=provider&app=claude&name=Test"; + + let result = parse_deeplink_url(url); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid scheme")); + } + + #[test] + fn test_parse_unsupported_version() { + let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test"; + + let result = parse_deeplink_url(url); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Unsupported protocol version")); + } + + #[test] + fn test_parse_missing_required_field() { + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test"; + + let result = parse_deeplink_url(url); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Missing 'homepage' parameter")); + } + + #[test] + fn test_validate_invalid_url() { + let result = validate_url("not-a-url", "test"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_invalid_scheme() { + let result = validate_url("ftp://example.com", "test"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("must be http or https")); + } + + #[test] + fn test_build_gemini_provider_with_model() { + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: "gemini".to_string(), + name: "Test Gemini".to_string(), + homepage: "https://example.com".to_string(), + endpoint: "https://api.example.com".to_string(), + api_key: "test-api-key".to_string(), + model: Some("gemini-2.0-flash".to_string()), + notes: None, + }; + + let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); + + // Verify provider basic info + assert_eq!(provider.name, "Test Gemini"); + assert_eq!( + provider.website_url, + Some("https://example.com".to_string()) + ); + + // Verify settings_config structure + let env = provider.settings_config["env"].as_object().unwrap(); + assert_eq!(env["GEMINI_API_KEY"], "test-api-key"); + assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com"); + assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash"); + } + + #[test] + fn test_build_gemini_provider_without_model() { + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: "gemini".to_string(), + name: "Test Gemini".to_string(), + homepage: "https://example.com".to_string(), + endpoint: "https://api.example.com".to_string(), + api_key: "test-api-key".to_string(), + model: None, + notes: None, + }; + + let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); + + // Verify settings_config structure + let env = provider.settings_config["env"].as_object().unwrap(); + assert_eq!(env["GEMINI_API_KEY"], "test-api-key"); + assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com"); + // Model should not be present + assert!(env.get("GEMINI_MODEL").is_none()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ab7774..0ed21ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod claude_plugin; mod codex_config; mod commands; mod config; +mod deeplink; mod error; mod gemini_config; // 新增 mod gemini_mcp; @@ -22,6 +23,7 @@ pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; +pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; pub use error::AppError; pub use mcp::{ import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude, @@ -36,6 +38,7 @@ pub use services::{ }; pub use settings::{update_settings, AppSettings}; pub use store::AppState; +use tauri_plugin_deep_link::DeepLinkExt; use std::sync::Arc; use tauri::{ @@ -283,6 +286,65 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { } } +/// 统一处理 ccswitch:// 深链接 URL +/// +/// - 解析 URL +/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件 +/// - 可选:在成功时聚焦主窗口 +fn handle_deeplink_url( + app: &tauri::AppHandle, + url_str: &str, + focus_main_window: bool, + source: &str, +) -> bool { + if !url_str.starts_with("ccswitch://") { + return false; + } + + log::info!("✓ Deep link URL detected from {source}: {url_str}"); + + match crate::deeplink::parse_deeplink_url(url_str) { + Ok(request) => { + log::info!( + "✓ Successfully parsed deep link: resource={}, app={}, name={}", + request.resource, + request.app, + request.name + ); + + if let Err(e) = app.emit("deeplink-import", &request) { + log::error!("✗ Failed to emit deeplink-import event: {e}"); + } else { + log::info!("✓ Emitted deeplink-import event to frontend"); + } + + if focus_main_window { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + log::info!("✓ Window shown and focused"); + } + } + } + Err(e) => { + log::error!("✗ Failed to parse deep link URL: {e}"); + + if let Err(emit_err) = app.emit( + "deeplink-error", + serde_json::json!({ + "url": url_str, + "error": e.to_string() + }), + ) { + log::error!("✗ Failed to emit deeplink-error event: {emit_err}"); + } + } + } + + true +} + // /// 内部切换供应商函数 @@ -348,7 +410,27 @@ pub fn run() { #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] { - builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + log::info!("=== Single Instance Callback Triggered ==="); + log::info!("Args count: {}", args.len()); + for (i, arg) in args.iter().enumerate() { + log::info!(" arg[{i}]: {arg}"); + } + + // Check for deep link URL in args (mainly for Windows/Linux command line) + let mut found_deeplink = false; + for arg in &args { + if handle_deeplink_url(app, arg, false, "single_instance args") { + found_deeplink = true; + break; + } + } + + if !found_deeplink { + log::info!("ℹ No deep link URL found in args (this is expected on macOS when launched via system)"); + } + + // Show and focus window regardless if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); @@ -358,6 +440,8 @@ pub fn run() { } let builder = builder + // 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接) + .plugin(tauri_plugin_deep_link::init()) // 拦截窗口关闭:根据设置决定是否最小化到托盘 .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -473,7 +557,40 @@ pub fn run() { config_guard.ensure_app(&app_config::AppType::Codex); } - // 启动阶段不再无条件保存,避免意外覆盖用户配置。 + // 启动阶段不再无条件保存,避免意外覆盖用户配置。 + + // 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API) + log::info!("=== Registering deep-link URL handler ==="); + + // 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)"); + } + } + + // 注册 URL 处理回调(所有平台通用) + app.deep_link().on_open_url({ + let app_handle = app.handle().clone(); + move |event| { + log::info!("=== Deep Link Event Received (on_open_url) ==="); + let urls = event.urls(); + log::info!("Received {} URL(s)", urls.len()); + + for (i, url) in urls.iter().enumerate() { + let url_str = url.as_str(); + log::info!(" URL[{i}]: {url_str}"); + + if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") { + break; // Process only first ccswitch:// URL + } + } + } + }); + log::info!("✓ Deep-link URL handler registered"); // 创建动态托盘菜单 let menu = create_tray_menu(app.handle(), &app_state)?; @@ -585,6 +702,9 @@ pub fn run() { commands::save_file_dialog, commands::open_file_dialog, commands::sync_current_providers_live, + // Deep link import + commands::parse_deeplink, + commands::import_from_deeplink, update_tray_menu, // Environment variable management commands::check_env_conflicts, @@ -605,17 +725,74 @@ pub fn run() { app.run(|app_handle, event| { #[cfg(target_os = "macos")] - // macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口 - if let RunEvent::Reopen { .. } = event { - if let Some(window) = app_handle.get_webview_window("main") { - #[cfg(target_os = "windows")] - { - let _ = window.set_skip_taskbar(false); + { + match event { + // macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口 + RunEvent::Reopen { .. } => { + if let Some(window) = app_handle.get_webview_window("main") { + #[cfg(target_os = "windows")] + { + let _ = window.set_skip_taskbar(false); + } + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + apply_tray_policy(app_handle, true); + } } - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - apply_tray_policy(app_handle, true); + // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...) + RunEvent::Opened { urls } => { + if let Some(url) = urls.first() { + let url_str = url.to_string(); + log::info!("RunEvent::Opened with URL: {url_str}"); + + if url_str.starts_with("ccswitch://") { + // 解析并广播深链接事件,复用与 single_instance 相同的逻辑 + match crate::deeplink::parse_deeplink_url(&url_str) { + Ok(request) => { + log::info!( + "Successfully parsed deep link from RunEvent::Opened: resource={}, app={}", + request.resource, + request.app + ); + + if let Err(e) = + app_handle.emit("deeplink-import", &request) + { + log::error!( + "Failed to emit deep link event from RunEvent::Opened: {e}" + ); + } + } + Err(e) => { + log::error!( + "Failed to parse deep link URL from RunEvent::Opened: {e}" + ); + + if let Err(emit_err) = app_handle.emit( + "deeplink-error", + serde_json::json!({ + "url": url_str, + "error": e.to_string() + }), + ) { + log::error!( + "Failed to emit deep link error event from RunEvent::Opened: {emit_err}" + ); + } + } + } + + // 确保主窗口可见 + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + } + } + } + _ => {} } } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 61a386e..8940ebc 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -536,7 +536,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result if !json_arr.is_empty() { Some(serde_json::Value::Array(json_arr)) } else { - log::debug!("跳过复杂数组字段 '{}' (TOML → JSON)", key); + log::debug!("跳过复杂数组字段 '{key}' (TOML → JSON)"); None } } @@ -551,19 +551,19 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result if !json_obj.is_empty() { Some(serde_json::Value::Object(json_obj)) } else { - log::debug!("跳过复杂对象字段 '{}' (TOML → JSON)", key); + log::debug!("跳过复杂对象字段 '{key}' (TOML → JSON)"); None } } toml::Value::Datetime(_) => { - log::debug!("跳过日期时间字段 '{}' (TOML → JSON)", key); + log::debug!("跳过日期时间字段 '{key}' (TOML → JSON)"); None } }; if let Some(val) = json_val { spec.insert(key.clone(), val); - log::debug!("导入扩展字段 '{}' = {:?}", key, toml_val); + log::debug!("导入扩展字段 '{key}' = {toml_val:?}"); } } @@ -831,7 +831,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option Result // 记录扩展字段的处理 if extended_fields.contains(&key.as_str()) { - log::debug!("已转换扩展字段 '{}' = {:?}", key, value); + log::debug!("已转换扩展字段 '{key}' = {value:?}"); } else { - log::info!("已转换自定义字段 '{}' = {:?}", key, value); + log::info!("已转换自定义字段 '{key}' = {value:?}"); } } } @@ -1094,7 +1094,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) { if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) { if servers.remove(id).is_some() { - log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{}'", id); + log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'"); } } } diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index b6aa159..e3b6298 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -22,6 +22,9 @@ pub struct Provider { #[serde(skip_serializing_if = "Option::is_none")] #[serde(rename = "sortIndex")] pub sort_index: Option, + /// 备注信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, /// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json) #[serde(skip_serializing_if = "Option::is_none")] pub meta: Option, @@ -43,6 +46,7 @@ impl Provider { category: None, created_at: None, sort_index: None, + notes: None, meta: None, } } diff --git a/src-tauri/src/services/env_checker.rs b/src-tauri/src/services/env_checker.rs index 1fe42a2..842bc3e 100644 --- a/src-tauri/src/services/env_checker.rs +++ b/src-tauri/src/services/env_checker.rs @@ -124,7 +124,9 @@ fn check_shell_configs(keywords: &[&str]) -> Result, String> { let trimmed = line.trim(); // Match patterns like: export VAR=value or VAR=value - if trimmed.starts_with("export ") || (!trimmed.starts_with('#') && trimmed.contains('=')) { + if trimmed.starts_with("export ") + || (!trimmed.starts_with('#') && trimmed.contains('=')) + { let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed); if let Some(eq_pos) = export_line.find('=') { @@ -135,7 +137,10 @@ fn check_shell_configs(keywords: &[&str]) -> Result, String> { if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) { conflicts.push(EnvConflict { var_name: var_name.to_string(), - var_value: var_value.trim_matches('"').trim_matches('\'').to_string(), + var_value: var_value + .trim_matches('"') + .trim_matches('\'') + .to_string(), source_type: "file".to_string(), source_path: format!("{}:{}", file_path, line_num + 1), }); diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs index ffb8aa2..0ee46da 100644 --- a/src-tauri/src/services/env_manager.rs +++ b/src-tauri/src/services/env_manager.rs @@ -43,11 +43,11 @@ pub fn delete_env_vars(conflicts: Vec) -> Result Result { // Get backup directory let backup_dir = get_backup_dir()?; - fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {}", e))?; + fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?; // Generate backup file name with timestamp let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); - let backup_file = backup_dir.join(format!("env-backup-{}.json", timestamp)); + let backup_file = backup_dir.join(format!("env-backup-{timestamp}.json")); // Create backup data let backup_info = BackupInfo { @@ -58,9 +58,9 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result { // Write backup file let json = serde_json::to_string_pretty(&backup_info) - .map_err(|e| format!("序列化备份数据失败: {}", e))?; + .map_err(|e| format!("序列化备份数据失败: {e}"))?; - fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {}", e))?; + fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?; Ok(backup_info) } @@ -115,7 +115,7 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> { // Read file content let content = fs::read_to_string(file_path) - .map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?; + .map_err(|e| format!("读取文件失败 {file_path}: {e}"))?; // Filter out the line containing the environment variable let new_content: Vec = content @@ -137,7 +137,7 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> { // Write back to file fs::write(file_path, new_content.join("\n")) - .map_err(|e| format!("写入文件失败 {}: {}", file_path, e))?; + .map_err(|e| format!("写入文件失败 {file_path}: {e}"))?; Ok(()) } @@ -152,11 +152,10 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> { /// Restore environment variables from backup pub fn restore_from_backup(backup_path: String) -> Result<(), String> { // Read backup file - let content = - fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {}", e))?; + let content = fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {e}"))?; - let backup_info: BackupInfo = serde_json::from_str(&content) - .map_err(|e| format!("解析备份文件失败: {}", e))?; + let backup_info: BackupInfo = + serde_json::from_str(&content).map_err(|e| format!("解析备份文件失败: {e}"))?; // Restore each variable for conflict in &backup_info.conflicts { @@ -190,7 +189,10 @@ fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> { } Ok(()) } - _ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)), + _ => Err(format!( + "无法恢复类型为 {} 的环境变量", + conflict.source_type + )), } } @@ -208,19 +210,21 @@ fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> { // Read file content let mut content = fs::read_to_string(file_path) - .map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?; + .map_err(|e| format!("读取文件失败 {file_path}: {e}"))?; // Append the environment variable line let export_line = format!("\nexport {}={}", conflict.var_name, conflict.var_value); content.push_str(&export_line); // Write back to file - fs::write(file_path, content) - .map_err(|e| format!("写入文件失败 {}: {}", file_path, e))?; + fs::write(file_path, content).map_err(|e| format!("写入文件失败 {file_path}: {e}"))?; Ok(()) } - _ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)), + _ => Err(format!( + "无法恢复类型为 {} 的环境变量", + conflict.source_type + )), } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4ae2c6b..e7c79f7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,11 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:" + "csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:", + "assetProtocol": { + "enable": true, + "scope": [] + } } }, "bundle": { @@ -42,9 +46,17 @@ "wix": { "template": "wix/per-user-main.wxs" } + }, + "macOS": { + "minimumSystemVersion": "10.15" } }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["ccswitch"] + } + }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK", "endpoints": [ diff --git a/src-tauri/tests/deeplink_import.rs b/src-tauri/tests/deeplink_import.rs new file mode 100644 index 0000000..d70a060 --- /dev/null +++ b/src-tauri/tests/deeplink_import.rs @@ -0,0 +1,121 @@ +use std::sync::RwLock; + +use cc_switch_lib::{ + import_provider_from_deeplink, parse_deeplink_url, AppState, AppType, MultiAppConfig, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, reset_test_fs, test_mutex}; + +#[test] +fn deeplink_import_claude_provider_persists_to_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let url = "ccswitch://v1/import?resource=provider&app=claude&name=DeepLink%20Claude&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test-claude-key&model=claude-sonnet-4"; + let request = parse_deeplink_url(url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + + let state = AppState { + config: RwLock::new(config), + }; + + let provider_id = import_provider_from_deeplink(&state, request.clone()) + .expect("import provider from deeplink"); + + // 验证内存状态 + let guard = state.config.read().expect("read config"); + let manager = guard + .get_manager(&AppType::Claude) + .expect("claude manager should exist"); + let provider = manager + .providers + .get(&provider_id) + .expect("provider created via deeplink"); + assert_eq!(provider.name, request.name); + assert_eq!( + provider.website_url.as_deref(), + Some(request.homepage.as_str()) + ); + let auth_token = provider + .settings_config + .pointer("/env/ANTHROPIC_AUTH_TOKEN") + .and_then(|v| v.as_str()); + let base_url = provider + .settings_config + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(|v| v.as_str()); + assert_eq!(auth_token, Some(request.api_key.as_str())); + assert_eq!(base_url, Some(request.endpoint.as_str())); + drop(guard); + + // 验证配置已持久化 + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + config_path.exists(), + "importing provider from deeplink should persist config.json" + ); +} + +#[test] +fn deeplink_import_codex_provider_builds_auth_and_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let url = "ccswitch://v1/import?resource=provider&app=codex&name=DeepLink%20Codex&homepage=https%3A%2F%2Fopenai.example&endpoint=https%3A%2F%2Fapi.openai.example%2Fv1&apiKey=sk-test-codex-key&model=gpt-4o"; + let request = parse_deeplink_url(url).expect("parse deeplink url"); + + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Codex); + + let state = AppState { + config: RwLock::new(config), + }; + + let provider_id = import_provider_from_deeplink(&state, request.clone()) + .expect("import provider from deeplink"); + + let guard = state.config.read().expect("read config"); + let manager = guard + .get_manager(&AppType::Codex) + .expect("codex manager should exist"); + let provider = manager + .providers + .get(&provider_id) + .expect("provider created via deeplink"); + assert_eq!(provider.name, request.name); + assert_eq!( + provider.website_url.as_deref(), + Some(request.homepage.as_str()) + ); + let auth_value = provider + .settings_config + .pointer("/auth/OPENAI_API_KEY") + .and_then(|v| v.as_str()); + let config_text = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + assert_eq!(auth_value, Some(request.api_key.as_str())); + assert!( + config_text.contains(request.endpoint.as_str()), + "config.toml content should contain endpoint" + ); + assert!( + config_text.contains("model = \"gpt-4o\""), + "config.toml content should contain model setting" + ); + drop(guard); + + let config_path = home.join(".cc-switch").join("config.json"); + assert!( + config_path.exists(), + "importing provider from deeplink should persist config.json" + ); +} diff --git a/src/App.tsx b/src/App.tsx index 1464c43..6402605 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; +import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -100,7 +101,10 @@ function App() { setShowEnvBanner(true); } } catch (error) { - console.error("[App] Failed to check environment conflicts on startup:", error); + console.error( + "[App] Failed to check environment conflicts on startup:", + error, + ); } }; @@ -117,17 +121,20 @@ function App() { // 合并新检测到的冲突 setEnvConflicts((prev) => { const existingKeys = new Set( - prev.map((c) => `${c.varName}:${c.sourcePath}`) + prev.map((c) => `${c.varName}:${c.sourcePath}`), ); const newConflicts = conflicts.filter( - (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`) + (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`), ); return [...prev, ...newConflicts]; }); setShowEnvBanner(true); } } catch (error) { - console.error("[App] Failed to check environment conflicts on app switch:", error); + console.error( + "[App] Failed to check environment conflicts on app switch:", + error, + ); } }; @@ -239,7 +246,10 @@ function App() { setShowEnvBanner(false); } } catch (error) { - console.error("[App] Failed to re-check conflicts after deletion:", error); + console.error( + "[App] Failed to re-check conflicts after deletion:", + error, + ); } }} /> @@ -402,6 +412,7 @@ function App() { setIsSkillsOpen(false)} /> + ); } diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx new file mode 100644 index 0000000..49f9f65 --- /dev/null +++ b/src/components/DeepLinkImportDialog.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; + +interface DeeplinkError { + url: string; + error: string; +} + +export function DeepLinkImportDialog() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [request, setRequest] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + // Listen for deep link import events + const unlistenImport = listen( + "deeplink-import", + (event) => { + console.log("Deep link import event received:", event.payload); + setRequest(event.payload); + setIsOpen(true); + }, + ); + + // Listen for deep link error events + const unlistenError = listen("deeplink-error", (event) => { + console.error("Deep link error:", event.payload); + toast.error(t("deeplink.parseError"), { + description: event.payload.error, + }); + }); + + return () => { + unlistenImport.then((fn) => fn()); + unlistenError.then((fn) => fn()); + }; + }, [t]); + + const handleImport = async () => { + if (!request) return; + + setIsImporting(true); + + try { + await deeplinkApi.importFromDeeplink(request); + + // Invalidate provider queries to refresh the list + await queryClient.invalidateQueries({ + queryKey: ["providers", request.app], + }); + + toast.success(t("deeplink.importSuccess"), { + description: t("deeplink.importSuccessDescription", { + name: request.name, + }), + }); + + setIsOpen(false); + setRequest(null); + } catch (error) { + console.error("Failed to import provider from deep link:", error); + toast.error(t("deeplink.importError"), { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsImporting(false); + } + }; + + const handleCancel = () => { + setIsOpen(false); + setRequest(null); + }; + + if (!request) return null; + + // Mask API key for display (show first 4 chars + ***) + const maskedApiKey = + request.apiKey.length > 4 + ? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}` + : "****"; + + return ( + + + {/* 标题显式左对齐,避免默认居中样式影响 */} + + {t("deeplink.confirmImport")} + + {t("deeplink.confirmImportDescription")} + + + + {/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */} +
+ {/* App Type */} +
+
+ {t("deeplink.app")} +
+
+ {request.app} +
+
+ + {/* Provider Name */} +
+
+ {t("deeplink.providerName")} +
+
{request.name}
+
+ + {/* Homepage */} +
+
+ {t("deeplink.homepage")} +
+
+ {request.homepage} +
+
+ + {/* API Endpoint */} +
+
+ {t("deeplink.endpoint")} +
+
+ {request.endpoint} +
+
+ + {/* API Key (masked) */} +
+
+ {t("deeplink.apiKey")} +
+
+ {maskedApiKey} +
+
+ + {/* Model (if present) */} + {request.model && ( +
+
+ {t("deeplink.model")} +
+
+ {request.model} +
+
+ )} + + {/* Notes (if present) */} + {request.notes && ( +
+
+ {t("deeplink.notes")} +
+
+ {request.notes} +
+
+ )} + + {/* Warning */} +
+ {t("deeplink.warning")} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx index 3ae21d0..76a167f 100644 --- a/src/components/env/EnvWarningBanner.tsx +++ b/src/components/env/EnvWarningBanner.tsx @@ -198,7 +198,8 @@ export function EnvWarningBanner({ {t("env.field.value")}: {conflict.varValue}

- {t("env.field.source")}: {getSourceDescription(conflict)} + {t("env.field.source")}:{" "} + {getSourceDescription(conflict)}

@@ -247,7 +248,9 @@ export function EnvWarningBanner({ {t("env.confirm.title")} -

{t("env.confirm.message", { count: selectedConflicts.size })}

+

+ {t("env.confirm.message", { count: selectedConflicts.size })} +

{t("env.confirm.backupNotice")}

diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 3e54bbf..526051a 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -1,7 +1,14 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Save, Plus, AlertCircle, ChevronDown, ChevronUp, Wand2 } from "lucide-react"; +import { + Save, + Plus, + AlertCircle, + ChevronDown, + ChevronUp, + Wand2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { diff --git a/src/components/mcp/McpWizardModal.tsx b/src/components/mcp/McpWizardModal.tsx index 9a663e4..f30e90f 100644 --- a/src/components/mcp/McpWizardModal.tsx +++ b/src/components/mcp/McpWizardModal.tsx @@ -80,7 +80,9 @@ const McpWizardModal: React.FC = ({ initialServer, }) => { const { t } = useTranslation(); - const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">("stdio"); + const [wizardType, setWizardType] = useState<"stdio" | "http" | "sse">( + "stdio", + ); const [wizardTitle, setWizardTitle] = useState(""); // stdio 字段 const [wizardCommand, setWizardCommand] = useState(""); diff --git a/src/components/mcp/useMcpValidation.ts b/src/components/mcp/useMcpValidation.ts index 53169dd..e65fcbf 100644 --- a/src/components/mcp/useMcpValidation.ts +++ b/src/components/mcp/useMcpValidation.ts @@ -76,10 +76,7 @@ export function useMcpValidation() { if (typ === "stdio" && !(obj as any)?.command?.trim()) { return t("mcp.error.commandRequired"); } - if ( - (typ === "http" || typ === "sse") && - !(obj as any)?.url?.trim() - ) { + if ((typ === "http" || typ === "sse") && !(obj as any)?.url?.trim()) { return t("mcp.wizard.urlRequired"); } } diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 53aa0e2..1f2b3d6 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -45,6 +45,7 @@ export function AddProviderDialog({ // 构造基础提交数据 const providerData: Omit = { name: values.name.trim(), + notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, settingsConfig: parsedConfig, ...(values.presetCategory ? { category: values.presetCategory } : {}), diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 46a79d9..aa221f7 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -93,6 +93,7 @@ export function EditProviderDialog({ const updatedProvider: Provider = { ...provider, name: values.name.trim(), + notes: values.notes?.trim() || undefined, websiteUrl: values.websiteUrl?.trim() || undefined, settingsConfig: parsedConfig, ...(values.presetCategory ? { category: values.presetCategory } : {}), @@ -129,6 +130,7 @@ export function EditProviderDialog({ onCancel={() => onOpenChange(false)} initialData={{ name: provider.name, + notes: provider.notes, websiteUrl: provider.websiteUrl, // 若读取到实时配置则优先使用 settingsConfig: initialSettingsConfig, diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index bb95b61..9c53333 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -33,10 +33,17 @@ interface ProviderCardProps { } const extractApiUrl = (provider: Provider, fallbackText: string) => { + // 优先级 1: 备注 + if (provider.notes?.trim()) { + return provider.notes.trim(); + } + + // 优先级 2: 官网地址 if (provider.websiteUrl) { return provider.websiteUrl; } + // 优先级 3: 从配置中提取请求地址 const config = provider.settingsConfig; if (config && typeof config === "object") { @@ -83,10 +90,24 @@ export function ProviderCard({ return extractApiUrl(provider, fallbackUrlText); }, [provider, fallbackUrlText]); + // 判断是否为可点击的 URL(备注不可点击) + const isClickableUrl = useMemo(() => { + // 如果有备注,则不可点击 + if (provider.notes?.trim()) { + return false; + } + // 如果显示的是回退文本,也不可点击 + if (displayUrl === fallbackUrlText) { + return false; + } + // 其他情况(官网地址或请求地址)可点击 + return true; + }, [provider.notes, displayUrl, fallbackUrlText]); + const usageEnabled = provider.meta?.usage_script?.enabled ?? false; const handleOpenWebsite = () => { - if (!displayUrl || displayUrl === fallbackUrlText) { + if (!isClickableUrl) { return; } onOpenWebsite(displayUrl); @@ -174,8 +195,14 @@ export function ProviderCard({ diff --git a/src/components/providers/forms/BasicFormFields.tsx b/src/components/providers/forms/BasicFormFields.tsx index daec379..f4e8a21 100644 --- a/src/components/providers/forms/BasicFormFields.tsx +++ b/src/components/providers/forms/BasicFormFields.tsx @@ -46,6 +46,20 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) { )} /> + + ( + + {t("provider.notes")} + + + + + + )} + /> ); } diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 400642a..fefe41c 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -26,6 +26,11 @@ interface CodexFormFieldsProps { onEndpointModalToggle: (open: boolean) => void; onCustomEndpointsChange?: (endpoints: string[]) => void; + // Model Name + shouldShowModelField?: boolean; + modelName?: string; + onModelNameChange?: (model: string) => void; + // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; } @@ -45,6 +50,9 @@ export function CodexFormFields({ isEndpointModalOpen, onEndpointModalToggle, onCustomEndpointsChange, + shouldShowModelField = true, + modelName = "", + onModelNameChange, speedTestEndpoints, }: CodexFormFieldsProps) { const { t } = useTranslation(); @@ -85,6 +93,33 @@ export function CodexFormFields({ /> )} + {/* Codex Model Name 输入框 */} + {shouldShowModelField && onModelNameChange && ( +
+ + onModelNameChange(e.target.value)} + placeholder={t("codexConfig.modelNamePlaceholder", { + defaultValue: "例如: gpt-5-codex", + })} + className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors" + /> +

+ {t("codexConfig.modelNameHint", { + defaultValue: "指定使用的模型,将自动更新到 config.toml 中", + })} +

+
+ )} + {/* 端点测速弹窗 - Codex */} {shouldShowSpeedTest && isEndpointModalOpen && ( ; category?: ProviderCategory; meta?: ProviderMeta; @@ -138,6 +139,7 @@ export function ProviderForm({ () => ({ name: initialData?.name ?? "", websiteUrl: initialData?.websiteUrl ?? "", + notes: initialData?.notes ?? "", settingsConfig: initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig, null, 2) : appId === "codex" @@ -200,10 +202,12 @@ export function ProviderForm({ codexConfig, codexApiKey, codexBaseUrl, + codexModelName, codexAuthError, setCodexAuth, handleCodexApiKeyChange, handleCodexBaseUrlChange, + handleCodexModelNameChange, handleCodexConfigChange: originalHandleCodexConfigChange, resetCodexConfig, } = useCodexConfigState({ initialData }); @@ -313,12 +317,14 @@ export function ProviderForm({ const { geminiEnv, geminiConfig, + geminiModel, envError, configError: geminiConfigError, handleGeminiEnvChange, handleGeminiConfigChange, resetGeminiConfig, envStringToObj, + envObjToString, } = useGeminiConfigState({ initialData: appId === "gemini" ? initialData : undefined, }); @@ -621,7 +627,6 @@ export function ProviderForm({ presetCategoryLabels={presetCategoryLabels} onPresetChange={handlePresetChange} category={category} - appId={appId} /> )} @@ -684,6 +689,9 @@ export function ProviderForm({ onCustomEndpointsChange={ isEditMode ? undefined : setDraftCustomEndpoints } + shouldShowModelField={category !== "official"} + modelName={codexModelName} + onModelNameChange={handleCodexModelNameChange} speedTestEndpoints={speedTestEndpoints} /> )} @@ -710,17 +718,19 @@ export function ProviderForm({ onEndpointModalToggle={setIsEndpointModalOpen} onCustomEndpointsChange={setDraftCustomEndpoints} shouldShowModelField={true} - model={ - form.watch("settingsConfig") - ? JSON.parse(form.watch("settingsConfig") || "{}")?.env - ?.GEMINI_MODEL || "" - : "" - } + 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); }} speedTestEndpoints={speedTestEndpoints} /> diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index dfd4c68..1927c5e 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -6,7 +6,6 @@ import type { ProviderPreset } from "@/config/claudeProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { GeminiProviderPreset } from "@/config/geminiProviderPresets"; import type { ProviderCategory } from "@/types"; -import type { AppId } from "@/lib/api"; type PresetEntry = { id: string; @@ -20,7 +19,6 @@ interface ProviderPresetSelectorProps { presetCategoryLabels: Record; onPresetChange: (value: string) => void; category?: ProviderCategory; // 当前选中的分类 - appId?: AppId; } export function ProviderPresetSelector({ @@ -30,7 +28,6 @@ export function ProviderPresetSelector({ presetCategoryLabels, onPresetChange, category, - appId, }: ProviderPresetSelectorProps) { const { t } = useTranslation(); diff --git a/src/components/providers/forms/hooks/useCodexConfigState.ts b/src/components/providers/forms/hooks/useCodexConfigState.ts index 5f2823e..60436a0 100644 --- a/src/components/providers/forms/hooks/useCodexConfigState.ts +++ b/src/components/providers/forms/hooks/useCodexConfigState.ts @@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { extractCodexBaseUrl, setCodexBaseUrl as setCodexBaseUrlInConfig, + extractCodexModelName, + setCodexModelName as setCodexModelNameInConfig, } from "@/utils/providerConfigUtils"; import { normalizeTomlText } from "@/utils/textNormalization"; @@ -20,9 +22,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { const [codexConfig, setCodexConfigState] = useState(""); const [codexApiKey, setCodexApiKey] = useState(""); const [codexBaseUrl, setCodexBaseUrl] = useState(""); + const [codexModelName, setCodexModelName] = useState(""); const [codexAuthError, setCodexAuthError] = useState(""); const isUpdatingCodexBaseUrlRef = useRef(false); + const isUpdatingCodexModelNameRef = useRef(false); // 初始化 Codex 配置(编辑模式) useEffect(() => { @@ -47,6 +51,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { setCodexBaseUrl(initialBaseUrl); } + // 提取 Model Name + const initialModelName = extractCodexModelName(configStr); + if (initialModelName) { + setCodexModelName(initialModelName); + } + // 提取 API Key try { if (auth && typeof auth.OPENAI_API_KEY === "string") { @@ -69,6 +79,17 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { } }, [codexConfig, codexBaseUrl]); + // 与 TOML 配置保持模型名称同步 + useEffect(() => { + if (isUpdatingCodexModelNameRef.current) { + return; + } + const extracted = extractCodexModelName(codexConfig) || ""; + if (extracted !== codexModelName) { + setCodexModelName(extracted); + } + }, [codexConfig, codexModelName]); + // 获取 API Key(从 auth JSON) const getCodexAuthApiKey = useCallback((authString: string): string => { try { @@ -157,7 +178,26 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { [setCodexConfig], ); - // 处理 config 变化(同步 Base URL) + // 处理 Codex Model Name 变化 + const handleCodexModelNameChange = useCallback( + (modelName: string) => { + const trimmed = modelName.trim(); + setCodexModelName(trimmed); + + if (!trimmed) { + return; + } + + isUpdatingCodexModelNameRef.current = true; + setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed)); + setTimeout(() => { + isUpdatingCodexModelNameRef.current = false; + }, 0); + }, + [setCodexConfig], + ); + + // 处理 config 变化(同步 Base URL 和 Model Name) const handleCodexConfigChange = useCallback( (value: string) => { // 归一化中文/全角/弯引号,避免 TOML 解析报错 @@ -170,8 +210,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { setCodexBaseUrl(extracted); } } + + if (!isUpdatingCodexModelNameRef.current) { + const extractedModel = extractCodexModelName(normalized) || ""; + if (extractedModel !== codexModelName) { + setCodexModelName(extractedModel); + } + } }, - [setCodexConfig, codexBaseUrl], + [setCodexConfig, codexBaseUrl, codexModelName], ); // 重置配置(用于预设切换) @@ -186,6 +233,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { setCodexBaseUrl(baseUrl); } + const modelName = extractCodexModelName(config); + if (modelName) { + setCodexModelName(modelName); + } else { + setCodexModelName(""); + } + // 提取 API Key try { if (auth && typeof auth.OPENAI_API_KEY === "string") { @@ -205,11 +259,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { codexConfig, codexApiKey, codexBaseUrl, + codexModelName, codexAuthError, setCodexAuth, setCodexConfig, handleCodexApiKeyChange, handleCodexBaseUrlChange, + handleCodexModelNameChange, handleCodexConfigChange, resetCodexConfig, getCodexAuthApiKey, diff --git a/src/components/providers/forms/hooks/useGeminiConfigState.ts b/src/components/providers/forms/hooks/useGeminiConfigState.ts index 4ab96e6..cad1220 100644 --- a/src/components/providers/forms/hooks/useGeminiConfigState.ts +++ b/src/components/providers/forms/hooks/useGeminiConfigState.ts @@ -17,6 +17,7 @@ export function useGeminiConfigState({ const [geminiConfig, setGeminiConfigState] = useState(""); const [geminiApiKey, setGeminiApiKey] = useState(""); const [geminiBaseUrl, setGeminiBaseUrl] = useState(""); + const [geminiModel, setGeminiModel] = useState(""); const [envError, setEnvError] = useState(""); const [configError, setConfigError] = useState(""); @@ -72,21 +73,25 @@ export function useGeminiConfigState({ const configObj = (config as any).config || {}; setGeminiConfigState(JSON.stringify(configObj, null, 2)); - // 提取 API Key 和 Base URL + // 提取 API Key、Base URL 和 Model if (typeof env.GEMINI_API_KEY === "string") { setGeminiApiKey(env.GEMINI_API_KEY); } if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") { setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL); } + if (typeof env.GEMINI_MODEL === "string") { + setGeminiModel(env.GEMINI_MODEL); + } } }, [initialData, envObjToString]); - // 从 geminiEnv 中提取并同步 API Key 和 Base URL + // 从 geminiEnv 中提取并同步 API Key、Base URL 和 Model useEffect(() => { const envObj = envStringToObj(geminiEnv); const extractedKey = envObj.GEMINI_API_KEY || ""; const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || ""; + const extractedModel = envObj.GEMINI_MODEL || ""; if (extractedKey !== geminiApiKey) { setGeminiApiKey(extractedKey); @@ -94,7 +99,10 @@ export function useGeminiConfigState({ if (extractedBaseUrl !== geminiBaseUrl) { setGeminiBaseUrl(extractedBaseUrl); } - }, [geminiEnv, envStringToObj]); + if (extractedModel !== geminiModel) { + setGeminiModel(extractedModel); + } + }, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]); // 验证 Gemini Config JSON const validateGeminiConfig = useCallback((value: string): string => { @@ -181,7 +189,7 @@ export function useGeminiConfigState({ setGeminiEnv(envString); setGeminiConfig(configString); - // 提取 API Key 和 Base URL + // 提取 API Key、Base URL 和 Model if (typeof env.GEMINI_API_KEY === "string") { setGeminiApiKey(env.GEMINI_API_KEY); } else { @@ -193,6 +201,12 @@ export function useGeminiConfigState({ } else { setGeminiBaseUrl(""); } + + if (typeof env.GEMINI_MODEL === "string") { + setGeminiModel(env.GEMINI_MODEL); + } else { + setGeminiModel(""); + } }, [envObjToString, setGeminiEnv, setGeminiConfig], ); @@ -202,6 +216,7 @@ export function useGeminiConfigState({ geminiConfig, geminiApiKey, geminiBaseUrl, + geminiModel, envError, configError, setGeminiEnv, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 779301f..31c8068 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -84,6 +84,8 @@ "name": "Provider Name", "namePlaceholder": "e.g., Claude Official", "websiteUrl": "Website URL", + "notes": "Notes", + "notesPlaceholder": "e.g., Company dedicated account", "configJson": "Config JSON", "writeCommonConfig": "Write common config", "editCommonConfigButton": "Edit common config", @@ -408,7 +410,6 @@ "errors": { "usage_query_failed": "Usage query failed" }, - "presetSelector": { "title": "Select Configuration Type", "custom": "Custom", @@ -690,5 +691,23 @@ "removeFailed": "Failed to remove", "skillCount": "{{count}} skills detected" } + }, + "deeplink": { + "confirmImport": "Confirm Import Provider", + "confirmImportDescription": "The following configuration will be imported from deep link into CC Switch", + "app": "App Type", + "providerName": "Provider Name", + "homepage": "Homepage", + "endpoint": "API Endpoint", + "apiKey": "API Key", + "model": "Model", + "notes": "Notes", + "import": "Import", + "importing": "Importing...", + "warning": "Please confirm the information above is correct before importing. You can edit or delete it later in the provider list.", + "parseError": "Failed to parse deep link", + "importSuccess": "Import successful", + "importSuccessDescription": "Provider \"{{name}}\" has been successfully imported", + "importError": "Failed to import" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e2fb059..19905a0 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -84,6 +84,8 @@ "name": "供应商名称", "namePlaceholder": "例如:Claude 官方", "websiteUrl": "官网链接", + "notes": "备注", + "notesPlaceholder": "例如:公司专用账号", "configJson": "配置 JSON", "writeCommonConfig": "写入通用配置", "editCommonConfigButton": "编辑通用配置", @@ -408,7 +410,6 @@ "errors": { "usage_query_failed": "用量查询失败" }, - "presetSelector": { "title": "选择配置类型", "custom": "自定义", @@ -690,5 +691,23 @@ "removeFailed": "删除失败", "skillCount": "识别到 {{count}} 个技能" } + }, + "deeplink": { + "confirmImport": "确认导入供应商配置", + "confirmImportDescription": "以下配置将导入到 CC Switch", + "app": "应用类型", + "providerName": "供应商名称", + "homepage": "官网地址", + "endpoint": "API 端点", + "apiKey": "API 密钥", + "model": "模型", + "notes": "备注", + "import": "导入", + "importing": "导入中...", + "warning": "请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。", + "parseError": "深链接解析失败", + "importSuccess": "导入成功", + "importSuccessDescription": "供应商 \"{{name}}\" 已成功导入", + "importError": "导入失败" } } diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts new file mode 100644 index 0000000..52f7712 --- /dev/null +++ b/src/lib/api/deeplink.ts @@ -0,0 +1,35 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface DeepLinkImportRequest { + version: string; + resource: string; + app: "claude" | "codex" | "gemini"; + name: string; + homepage: string; + endpoint: string; + apiKey: string; + model?: string; + notes?: string; +} + +export const deeplinkApi = { + /** + * Parse a deep link URL + * @param url The ccswitch:// URL to parse + * @returns Parsed deep link request + */ + parseDeeplink: async (url: string): Promise => { + return invoke("parse_deeplink", { url }); + }, + + /** + * Import a provider from a deep link request + * @param request The deep link import request + * @returns The ID of the imported provider + */ + importFromDeeplink: async ( + request: DeepLinkImportRequest, + ): Promise => { + return invoke("import_from_deeplink", { request }); + }, +}; diff --git a/src/lib/schemas/provider.ts b/src/lib/schemas/provider.ts index 62b203a..d3ce7a7 100644 --- a/src/lib/schemas/provider.ts +++ b/src/lib/schemas/provider.ts @@ -38,6 +38,7 @@ function parseJsonError(error: unknown): string { export const providerSchema = z.object({ name: z.string().min(1, "请填写供应商名称"), websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")), + notes: z.string().optional(), settingsConfig: z .string() .min(1, "请填写配置内容") diff --git a/src/types.ts b/src/types.ts index 40fc5d1..6701676 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,8 @@ export interface Provider { category?: ProviderCategory; createdAt?: number; // 添加时间戳(毫秒) sortIndex?: number; // 排序索引(用于自定义拖拽排序) + // 备注信息 + notes?: string; // 新增:是否为商业合作伙伴 isPartner?: boolean; // 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置) diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 70e3718..0f5a52c 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -35,7 +35,7 @@ export function parseSmartMcpJson(jsonText: string): { } // 如果是键值对片段("key": {...}),包装成完整对象 - if (trimmed.startsWith('"') && !trimmed.startsWith('{')) { + if (trimmed.startsWith('"') && !trimmed.startsWith("{")) { trimmed = `{${trimmed}}`; } diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 7973b31..e2e73a1 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -467,3 +467,66 @@ export const setCodexBaseUrl = ( : normalizedText; return `${prefix}${replacementLine}\n`; }; + +// ========== Codex model name utils ========== + +// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号) +export const extractCodexModelName = ( + configText: string | undefined | null, +): string | undefined => { + try { + const raw = typeof configText === "string" ? configText : ""; + // 归一化中文/全角引号,避免正则提取失败 + const text = normalizeQuotes(raw); + if (!text) return undefined; + + // 匹配 model = "xxx" 或 model = 'xxx' + const m = text.match(/^model\s*=\s*(['"])([^'"]+)\1/m); + return m && m[2] ? m[2] : undefined; + } catch { + return undefined; + } +}; + +// 在 Codex 的 TOML 配置文本中写入或更新 model 字段 +export const setCodexModelName = ( + configText: string, + modelName: string, +): string => { + const trimmed = modelName.trim(); + if (!trimmed) { + return configText; + } + + // 归一化原文本中的引号(既能匹配,也能输出稳定格式) + const normalizedText = normalizeQuotes(configText); + + const replacementLine = `model = "${trimmed}"`; + const pattern = /^model\s*=\s*["']([^"']+)["']/m; + + if (pattern.test(normalizedText)) { + return normalizedText.replace(pattern, replacementLine); + } + + // 如果不存在 model 字段,尝试在 model_provider 之后插入 + // 如果 model_provider 也不存在,则插入到开头 + const providerPattern = /^model_provider\s*=\s*["'][^"']+["']/m; + const match = normalizedText.match(providerPattern); + + if (match && match.index !== undefined) { + // 在 model_provider 行之后插入 + const endOfLine = normalizedText.indexOf("\n", match.index); + if (endOfLine !== -1) { + return ( + normalizedText.slice(0, endOfLine + 1) + + replacementLine + + "\n" + + normalizedText.slice(endOfLine + 1) + ); + } + } + + // 在文件开头插入 + const lines = normalizedText.split("\n"); + return `${replacementLine}\n${lines.join("\n")}`; +}; diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx index 4f75237..ce01b5d 100644 --- a/tests/components/McpFormModal.test.tsx +++ b/tests/components/McpFormModal.test.tsx @@ -220,7 +220,7 @@ describe("McpFormModal", () => { }); it("缺少配置命令时阻止提交并提示错误", async () => { - const { onSave } = renderForm(); + renderForm(); fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), { target: { value: "no-command" }, @@ -288,7 +288,7 @@ command = "run" }); it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => { - const { onSave } = renderForm({ defaultFormat: "toml" }); + renderForm({ defaultFormat: "toml" }); // 填写 ID 字段 fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {