From 461ba6f4182a9adc7f65468d4ed04c580ced7dbd Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 18 Nov 2025 02:03:12 +0800 Subject: [PATCH] feat(deeplink): implement ccswitch:// protocol for provider import Add deep link support to enable one-click provider configuration import via ccswitch:// URLs. Backend: - Implement URL parsing and validation (src-tauri/src/deeplink.rs) - Add Tauri commands for parse and import (src-tauri/src/commands/deeplink.rs) - Register ccswitch:// protocol in macOS Info.plist - Add comprehensive unit tests (src-tauri/tests/deeplink_import.rs) Frontend: - Create confirmation dialog with security review UI (src/components/DeepLinkImportDialog.tsx) - Add API wrapper (src/lib/api/deeplink.ts) - Integrate event listeners in App.tsx Configuration: - Update Tauri config for deep link handling - Add i18n support for Chinese and English - Include test page for deep link validation (deeplink-test.html) Files: 15 changed, 1312 insertions(+) --- deeplink-test.html | 548 ++++++++++++++++++++++++ src-tauri/Info.plist | 19 + src-tauri/src/commands/deeplink.rs | 29 ++ src-tauri/src/deeplink.rs | 354 +++++++++++++++ src-tauri/tests/deeplink_import.rs | 121 ++++++ src/components/DeepLinkImportDialog.tsx | 206 +++++++++ src/lib/api/deeplink.ts | 35 ++ 7 files changed, 1312 insertions(+) create mode 100644 deeplink-test.html create mode 100644 src-tauri/Info.plist create mode 100644 src-tauri/src/commands/deeplink.rs create mode 100644 src-tauri/src/deeplink.rs create mode 100644 src-tauri/tests/deeplink_import.rs create mode 100644 src/components/DeepLinkImportDialog.tsx create mode 100644 src/lib/api/deeplink.ts diff --git a/deeplink-test.html b/deeplink-test.html new file mode 100644 index 0000000..fd213eb --- /dev/null +++ b/deeplink-test.html @@ -0,0 +1,548 @@ + + + + + + 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/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/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/deeplink.rs b/src-tauri/src/deeplink.rs new file mode 100644 index 0000000..ffde895 --- /dev/null +++ b/src-tauri/src/deeplink.rs @@ -0,0 +1,354 @@ +/// 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")); + } +} 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/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx new file mode 100644 index 0000000..0c21a1c --- /dev/null +++ b/src/components/DeepLinkImportDialog.tsx @@ -0,0 +1,206 @@ +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/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 }); + }, +};