diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 4111089e2..c2d849686 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -55,7 +55,7 @@ pub struct DeepLinkImportRequest { /// Provider homepage URL #[serde(skip_serializing_if = "Option::is_none")] pub homepage: Option, - /// API endpoint/base URL + /// API endpoint/base URL (supports comma-separated multiple URLs) #[serde(skip_serializing_if = "Option::is_none")] pub endpoint: Option, /// API key diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 61cf7b68f..58553d442 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -101,9 +101,13 @@ fn parse_provider_deeplink( validate_url(hp, "homepage")?; } } + // Validate each endpoint (supports comma-separated multiple URLs) if let Some(ref ep) = endpoint { - if !ep.is_empty() { - validate_url(ep, "endpoint")?; + for (i, url) in ep.split(',').enumerate() { + let trimmed = url.trim(); + if !trimmed.is_empty() { + validate_url(trimmed, &format!("endpoint[{i}]"))?; + } } } diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 8f2d00b1a..7dab0e292 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -51,15 +51,21 @@ pub fn import_provider_from_deeplink( )); } - let endpoint = merged_request.endpoint.as_ref().ok_or_else(|| { + // Get endpoint: supports comma-separated multiple URLs (first is primary) + let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| { AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string()) })?; - if endpoint.is_empty() { - return Err(AppError::InvalidInput( - "Endpoint cannot be empty".to_string(), - )); - } + // Parse endpoints: split by comma, first is primary + let all_endpoints: Vec = endpoint_str + .split(',') + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()) + .collect(); + + let _endpoint = all_endpoints + .first() + .ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?; let homepage = merged_request.homepage.as_ref().ok_or_else(|| { AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string()) @@ -97,6 +103,21 @@ pub fn import_provider_from_deeplink( // Use ProviderService to add the provider ProviderService::add(state, app_type.clone(), provider)?; + // Add extra endpoints as custom endpoints (skip first one as it's the primary) + for ep in all_endpoints.iter().skip(1) { + let normalized = ep.trim().trim_end_matches('/').to_string(); + if !normalized.is_empty() { + if let Err(e) = ProviderService::add_custom_endpoint( + state, + app_type.clone(), + &provider_id, + normalized.clone(), + ) { + log::warn!("Failed to add custom endpoint '{normalized}': {e}"); + } + } + } + // If enabled=true, set as current provider if merged_request.enabled.unwrap_or(false) { ProviderService::switch(state, app_type.clone(), &provider_id)?; @@ -138,6 +159,16 @@ pub(crate) fn build_provider_from_request( Ok(provider) } +/// Get primary endpoint from request (first one if comma-separated) +fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String { + request + .endpoint + .as_ref() + .and_then(|ep| ep.split(',').next()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + /// Build provider meta with usage script configuration fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, AppError> { // Check if any usage script fields are provided @@ -198,7 +229,7 @@ fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value { ); env.insert( "ANTHROPIC_BASE_URL".to_string(), - json!(request.endpoint.clone().unwrap_or_default()), + json!(get_primary_endpoint(request)), ); // Add default model if provided @@ -271,11 +302,8 @@ fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value { .unwrap_or("gpt-5-codex") .to_string(); - // Endpoint: normalize trailing slashes - let endpoint = request - .endpoint - .as_deref() - .unwrap_or("") + // Endpoint: normalize trailing slashes (use primary endpoint only) + let endpoint = get_primary_endpoint(request) .trim() .trim_end_matches('/') .to_string(); @@ -309,7 +337,7 @@ fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value { env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key)); env.insert( "GOOGLE_GEMINI_BASE_URL".to_string(), - json!(request.endpoint), + json!(get_primary_endpoint(request)), ); // Add model if provided diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 5775fc58d..f3e8839e7 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -404,3 +404,44 @@ fn test_parse_skill_deeplink() { assert_eq!(request.directory.unwrap(), "skills"); assert_eq!(request.branch.unwrap(), "dev"); } + +// ============================================================================= +// Multiple Endpoints Tests +// ============================================================================= + +#[test] +fn test_parse_multiple_endpoints_comma_separated() { + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test"; + + let request = parse_deeplink_url(url).unwrap(); + + assert!(request.endpoint.is_some()); + let endpoint = request.endpoint.unwrap(); + // Should contain all endpoints comma-separated + assert!(endpoint.contains("https://api1.example.com")); + assert!(endpoint.contains("https://api2.example.com")); + assert!(endpoint.contains("https://api3.example.com")); +} + +#[test] +fn test_parse_single_endpoint_backward_compatible() { + // Old format with single endpoint should still work + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test"; + + let request = parse_deeplink_url(url).unwrap(); + + assert_eq!( + request.endpoint, + Some("https://api.example.com".to_string()) + ); +} + +#[test] +fn test_parse_endpoints_with_spaces_trimmed() { + let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test"; + + let request = parse_deeplink_url(url).unwrap(); + + // Validation should pass (spaces are trimmed during validation) + assert!(request.endpoint.is_some()); +}