/// 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 // Generate TOML string for config.toml let mut config_toml = format!( r#"[api] base_url = "{}" "#, request.endpoint ); // Add model if provided if let Some(model) = &request.model { config_toml.push_str(&format!("model = \"{model}\"\n")); } 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("GOOGLE_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")); } }