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.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/mod.rs b/src-tauri/src/commands/mod.rs index 837d9af..1a4ab63 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ mod config; mod env; +mod deeplink; mod import_export; mod mcp; mod misc; @@ -13,6 +14,7 @@ pub mod skill; pub use config::*; pub use env::*; +pub use deeplink::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs new file mode 100644 index 0000000..058e43e --- /dev/null +++ b/src-tauri/src/deeplink.rs @@ -0,0 +1,407 @@ +/// 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("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/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/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/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..3380b08 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, @@ -370,8 +371,8 @@ function App() { message={ confirmDelete ? t("confirm.deleteProviderMessage", { - name: confirmDelete.name, - }) + name: confirmDelete.name, + }) : "" } onConfirm={() => void handleConfirmDelete()} @@ -402,6 +403,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/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/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index edafc9b..8ed4e5e 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -74,6 +74,7 @@ interface ProviderFormProps { initialData?: { name?: string; websiteUrl?: string; + notes?: string; settingsConfig?: Record; 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" @@ -621,7 +623,6 @@ export function ProviderForm({ presetCategoryLabels={presetCategoryLabels} onPresetChange={handlePresetChange} category={category} - appId={appId} /> )} 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/i18n/locales/en.json b/src/i18n/locales/en.json index 15b6612..e8c56cb 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", @@ -645,6 +646,8 @@ }, "error": { "noSelection": "Please select environment variables to delete" + } + }, "skills": { "manage": "Skills", "title": "Claude Skills Management", @@ -688,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" } -} +} \ No newline at end of file diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7d1fced..9ff7776 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": "自定义", @@ -645,6 +646,8 @@ }, "error": { "noSelection": "请选择要删除的环境变量" + } + }, "skills": { "manage": "Skills", "title": "Claude Skills 管理", @@ -688,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": "导入失败" } -} +} \ No newline at end of file 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/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"), {