From 939a2e4f2baf57a4314be429789bf43800f337d3 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 22 Nov 2025 14:00:15 +0800 Subject: [PATCH] feat(deeplink): add config file support for deeplink import Support importing provider configuration from embedded or remote config files. - Add base64 dependency for config content encoding - Support config, configFormat, and configUrl parameters - Make homepage/endpoint/apiKey optional when config is provided - Add config parsing and merging logic --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/deeplink.rs | 435 ++++++++++++++++++++++-- src/components/DeepLinkImportDialog.tsx | 136 +++----- 4 files changed, 458 insertions(+), 115 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3ae2f08..9d9948f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -610,6 +610,7 @@ version = "3.7.0" dependencies = [ "anyhow", "auto-launch", + "base64 0.22.1", "chrono", "dirs 5.0.1", "futures", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f7043da..bd27989 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ tempfile = "3" url = "2.5" auto-launch = "0.5" once_cell = "1.21.3" +base64 = "0.22" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs index f99a31b..5fa18ef 100644 --- a/src-tauri/src/deeplink.rs +++ b/src-tauri/src/deeplink.rs @@ -46,6 +46,15 @@ pub struct DeepLinkImportRequest { /// Optional Opus model (Claude only, v3.7.1+) #[serde(skip_serializing_if = "Option::is_none")] pub opus_model: Option, + /// Optional Base64 encoded config content (v3.8+) + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option, + /// Optional config format (json/toml, v3.8+) + #[serde(skip_serializing_if = "Option::is_none")] + pub config_format: Option, + /// Optional remote config URL (v3.8+) + #[serde(skip_serializing_if = "Option::is_none")] + pub config_url: Option, } /// Parse a ccswitch:// URL into a DeepLinkImportRequest @@ -119,24 +128,18 @@ pub fn parse_deeplink_url(url_str: &str) -> Result Result Result Result<(), AppError> { /// /// This function: /// 1. Validates the request -/// 2. Converts it to a Provider structure -/// 3. Delegates to ProviderService for actual import +/// 2. Merges config file if provided (v3.8+) +/// 3. Converts it to a Provider structure +/// 4. Delegates to ProviderService for actual import pub fn import_provider_from_deeplink( state: &AppState, request: DeepLinkImportRequest, ) -> Result { + // Step 1: Merge config file if provided (v3.8+) + let merged_request = parse_and_merge_config(&request)?; + + // Step 2: Validate required fields after merge + if merged_request.api_key.is_empty() { + return Err(AppError::InvalidInput( + "API key is required (either in URL or config file)".to_string(), + )); + } + if merged_request.endpoint.is_empty() { + return Err(AppError::InvalidInput( + "Endpoint is required (either in URL or config file)".to_string(), + )); + } + if merged_request.homepage.is_empty() { + return Err(AppError::InvalidInput( + "Homepage is required (either in URL or config file)".to_string(), + )); + } + // Parse app type - let app_type = AppType::from_str(&request.app) - .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?; + let app_type = AppType::from_str(&merged_request.app) + .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", merged_request.app)))?; // Build provider configuration based on app type - let mut provider = build_provider_from_request(&app_type, &request)?; + let mut provider = build_provider_from_request(&app_type, &merged_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 + let sanitized_name = merged_request .name .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') @@ -235,13 +267,22 @@ fn build_provider_from_request( // Add Claude-specific model fields (v3.7.1+) if let Some(haiku_model) = &request.haiku_model { - env.insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), json!(haiku_model)); + env.insert( + "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), + json!(haiku_model), + ); } if let Some(sonnet_model) = &request.sonnet_model { - env.insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), json!(sonnet_model)); + env.insert( + "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), + json!(sonnet_model), + ); } if let Some(opus_model) = &request.opus_model { - env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), json!(opus_model)); + env.insert( + "ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), + json!(opus_model), + ); } json!({ "env": env }) @@ -354,6 +395,247 @@ requires_openai_auth = true Ok(provider) } +/// Parse and merge configuration from Base64 encoded config or remote URL +/// +/// Priority: URL params > inline config > remote config +fn parse_and_merge_config( + request: &DeepLinkImportRequest, +) -> Result { + use base64::prelude::*; + + // If no config provided, return original request + if request.config.is_none() && request.config_url.is_none() { + return Ok(request.clone()); + } + + // Step 1: Get config content + let config_content = if let Some(config_b64) = &request.config { + // Decode Base64 inline config + let decoded = BASE64_STANDARD + .decode(config_b64) + .map_err(|e| AppError::InvalidInput(format!("Invalid Base64 encoding: {e}")))?; + String::from_utf8(decoded) + .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))? + } else if let Some(_config_url) = &request.config_url { + // Fetch remote config (TODO: implement remote fetching in next phase) + return Err(AppError::InvalidInput( + "Remote config URL is not yet supported. Use inline config instead.".to_string(), + )); + } else { + return Ok(request.clone()); + }; + + // Step 2: Parse config based on format + let format = request.config_format.as_deref().unwrap_or("json"); + let config_value: serde_json::Value = match format { + "json" => serde_json::from_str(&config_content) + .map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?, + "toml" => { + let toml_value: toml::Value = toml::from_str(&config_content) + .map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?; + // Convert TOML to JSON for uniform processing + serde_json::to_value(toml_value) + .map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))? + } + _ => { + return Err(AppError::InvalidInput(format!( + "Unsupported config format: {format}" + ))) + } + }; + + // Step 3: Extract values from config based on app type and merge with URL params + let mut merged = request.clone(); + + match request.app.as_str() { + "claude" => merge_claude_config(&mut merged, &config_value)?, + "codex" => merge_codex_config(&mut merged, &config_value)?, + "gemini" => merge_gemini_config(&mut merged, &config_value)?, + _ => { + return Err(AppError::InvalidInput(format!( + "Invalid app type: {}", + request.app + ))) + } + } + + Ok(merged) +} + +/// Merge Claude configuration from config file +/// +/// Priority: URL params override config file values +fn merge_claude_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + let env = config + .get("env") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + AppError::InvalidInput("Claude config must have 'env' object".to_string()) + })?; + + // Auto-fill API key if not provided in URL + if request.api_key.is_empty() { + if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) { + request.api_key = token.to_string(); + } + } + + // Auto-fill endpoint if not provided in URL + if request.endpoint.is_empty() { + if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { + request.endpoint = base_url.to_string(); + } + } + + // Auto-fill homepage from endpoint if not provided + if request.homepage.is_empty() && !request.endpoint.is_empty() { + request.homepage = infer_homepage_from_endpoint(&request.endpoint) + .unwrap_or_else(|| "https://anthropic.com".to_string()); + } + + // Auto-fill model fields (URL params take priority) + if request.model.is_none() { + request.model = env + .get("ANTHROPIC_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if request.haiku_model.is_none() { + request.haiku_model = env + .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if request.sonnet_model.is_none() { + request.sonnet_model = env + .get("ANTHROPIC_DEFAULT_SONNET_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if request.opus_model.is_none() { + request.opus_model = env + .get("ANTHROPIC_DEFAULT_OPUS_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + + Ok(()) +} + +/// Merge Codex configuration from config file +fn merge_codex_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + // Auto-fill API key from auth.OPENAI_API_KEY + if request.api_key.is_empty() { + if let Some(api_key) = config + .get("auth") + .and_then(|v| v.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()) + { + request.api_key = api_key.to_string(); + } + } + + // Auto-fill endpoint and model from config string + if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) { + // Parse TOML config string to extract base_url and model + if let Ok(toml_value) = toml::from_str::(config_str) { + // Extract base_url from model_providers section + if request.endpoint.is_empty() { + if let Some(base_url) = extract_codex_base_url(&toml_value) { + request.endpoint = base_url; + } + } + + // Extract model + if request.model.is_none() { + if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) { + request.model = Some(model.to_string()); + } + } + } + } + + // Auto-fill homepage from endpoint + if request.homepage.is_empty() && !request.endpoint.is_empty() { + request.homepage = infer_homepage_from_endpoint(&request.endpoint) + .unwrap_or_else(|| "https://openai.com".to_string()); + } + + Ok(()) +} + +/// Merge Gemini configuration from config file +fn merge_gemini_config( + request: &mut DeepLinkImportRequest, + config: &serde_json::Value, +) -> Result<(), AppError> { + // Gemini uses flat env structure + if request.api_key.is_empty() { + if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { + request.api_key = api_key.to_string(); + } + } + + if request.endpoint.is_empty() { + if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) { + request.endpoint = base_url.to_string(); + } + } + + if request.model.is_none() { + request.model = config + .get("GEMINI_MODEL") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + + // Auto-fill homepage from endpoint + if request.homepage.is_empty() && !request.endpoint.is_empty() { + request.homepage = infer_homepage_from_endpoint(&request.endpoint) + .unwrap_or_else(|| "https://ai.google.dev".to_string()); + } + + Ok(()) +} + +/// Extract base_url from Codex TOML config +fn extract_codex_base_url(toml_value: &toml::Value) -> Option { + // Try to find base_url in model_providers section + if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) { + for (_key, provider) in providers.iter() { + if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) { + return Some(base_url.to_string()); + } + } + } + None +} + +/// Infer homepage URL from API endpoint +/// +/// Examples: +/// - https://api.anthropic.com/v1 → https://anthropic.com +/// - https://api.openai.com/v1 → https://openai.com +/// - https://api-test.company.com/v1 → https://company.com +fn infer_homepage_from_endpoint(endpoint: &str) -> Option { + let url = Url::parse(endpoint).ok()?; + let host = url.host_str()?; + + // Remove common API prefixes + let clean_host = host + .strip_prefix("api.") + .or_else(|| host.strip_prefix("api-")) + .unwrap_or(host); + + Some(format!("https://{clean_host}")) +} + #[cfg(test)] mod tests { use super::*; @@ -405,14 +687,15 @@ mod tests { #[test] fn test_parse_missing_required_field() { - let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test"; + // Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional) + let url = "ccswitch://v1/import?resource=provider&app=claude"; let result = parse_deeplink_url(url); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() - .contains("Missing 'homepage' parameter")); + .contains("Missing 'name' parameter")); } #[test] @@ -443,6 +726,12 @@ mod tests { api_key: "test-api-key".to_string(), model: Some("gemini-2.0-flash".to_string()), notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, }; let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); @@ -473,6 +762,12 @@ mod tests { api_key: "test-api-key".to_string(), model: None, notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: None, + config_format: None, + config_url: None, }; let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap(); @@ -484,4 +779,88 @@ mod tests { // Model should not be present assert!(env.get("GEMINI_MODEL").is_none()); } + + #[test] + fn test_infer_homepage() { + assert_eq!( + infer_homepage_from_endpoint("https://api.anthropic.com/v1"), + Some("https://anthropic.com".to_string()) + ); + assert_eq!( + infer_homepage_from_endpoint("https://api-test.company.com/v1"), + Some("https://test.company.com".to_string()) + ); + assert_eq!( + infer_homepage_from_endpoint("https://example.com"), + Some("https://example.com".to_string()) + ); + } + + #[test] + fn test_parse_and_merge_config_claude() { + use base64::prelude::*; + + // Prepare Base64 encoded Claude config + let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#; + let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes()); + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: "claude".to_string(), + name: "Test".to_string(), + homepage: String::new(), + endpoint: String::new(), + api_key: String::new(), + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: Some(config_b64), + config_format: Some("json".to_string()), + config_url: None, + }; + + let merged = parse_and_merge_config(&request).unwrap(); + + // Should auto-fill from config + assert_eq!(merged.api_key, "sk-ant-xxx"); + assert_eq!(merged.endpoint, "https://api.anthropic.com/v1"); + assert_eq!(merged.homepage, "https://anthropic.com"); + assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string())); + } + + #[test] + fn test_parse_and_merge_config_url_override() { + use base64::prelude::*; + + let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#; + let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes()); + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: "claude".to_string(), + name: "Test".to_string(), + homepage: String::new(), + endpoint: String::new(), + api_key: "sk-new".to_string(), // URL param should override + model: None, + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + config: Some(config_b64), + config_format: Some("json".to_string()), + config_url: None, + }; + + let merged = parse_and_merge_config(&request).unwrap(); + + // URL param should take priority + assert_eq!(merged.api_key, "sk-new"); + // Config file value should be used + assert_eq!(merged.endpoint, "https://api.anthropic.com/v1"); + } } diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 9e2fed0..49f9f65 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -96,8 +96,8 @@ export function DeepLinkImportDialog() { : "****"; return ( - - + + {/* 标题显式左对齐,避免默认居中样式影响 */} {t("deeplink.confirmImport")} @@ -106,120 +106,82 @@ export function DeepLinkImportDialog() { - {/* 使用两列布局压缩内容 */} -
- {/* 第一行:应用类型 + 供应商名称 */} -
-
-
- {t("deeplink.app")} -
-
- {request.app} -
+ {/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */} +
+ {/* App Type */} +
+
+ {t("deeplink.app")}
-
-
- {t("deeplink.providerName")} -
-
- {request.name} -
+
+ {request.app}
- {/* 第二行:官网 + 端点 */} -
-
-
- {t("deeplink.homepage")} -
-
- {request.homepage} -
+ {/* Provider Name */} +
+
+ {t("deeplink.providerName")}
-
-
- {t("deeplink.endpoint")} -
-
- {request.endpoint} -
+
{request.name}
+
+ + {/* Homepage */} +
+
+ {t("deeplink.homepage")} +
+
+ {request.homepage}
- {/* 第三行:API Key */} -
-
+ {/* API Endpoint */} +
+
+ {t("deeplink.endpoint")} +
+
+ {request.endpoint} +
+
+ + {/* API Key (masked) */} +
+
{t("deeplink.apiKey")}
-
+
{maskedApiKey}
- {/* 第四行:默认模型(如果有) */} + {/* Model (if present) */} {request.model && ( -
-
+
+
{t("deeplink.model")}
-
{request.model}
-
- )} - - {/* Claude 专用模型字段(紧凑布局) */} - {request.app === "claude" && (request.haikuModel || request.sonnetModel || request.opusModel) && ( -
-
- {t("deeplink.claudeModels", "Claude 模型配置")} -
- -
- {request.haikuModel && ( -
- Haiku: -
- {request.haikuModel} -
-
- )} - - {request.sonnetModel && ( -
- Sonnet: -
- {request.sonnetModel} -
-
- )} - - {request.opusModel && ( -
- Opus: -
- {request.opusModel} -
-
- )} +
+ {request.model}
)} - {/* 备注(如果有) */} + {/* Notes (if present) */} {request.notes && ( -
-
+
+
{t("deeplink.notes")}
-
+
{request.notes}
)} - {/* 警告提示(紧凑版) */} -
+ {/* Warning */} +
{t("deeplink.warning")}