From 3d69da5b66cc3247cbe024fde8d5af23f99f70df Mon Sep 17 00:00:00 2001
From: YoVinchen
Date: Wed, 19 Nov 2025 09:03:18 +0800
Subject: [PATCH] feat: add model configuration support and fix Gemini
deeplink bug (#251)
* feat(providers): add notes field for provider management
- Add notes field to Provider model (backend and frontend)
- Display notes with higher priority than URL in provider card
- Style notes as non-clickable text to differentiate from URLs
- Add notes input field in provider form
- Add i18n support (zh/en) for notes field
* chore: format code and clean up unused props
- Run cargo fmt on Rust backend code
- Format TypeScript imports and code style
- Remove unused appId prop from ProviderPresetSelector
- Clean up unused variables in tests
- Integrate notes field handling in provider dialogs
* 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(+)
* chore(deeplink): integrate deep link handling into app lifecycle
Wire up deep link infrastructure with app initialization and event handling.
Backend Integration:
- Register deep link module and commands in mod.rs
- Add URL handling in app setup (src-tauri/src/lib.rs:handle_deeplink_url)
- Handle deep links from single instance callback (Windows/Linux CLI)
- Handle deep links from macOS system events
- Add tauri-plugin-deep-link dependency (Cargo.toml)
Frontend Integration:
- Listen for deeplink-import/deeplink-error events in App.tsx
- Update DeepLinkImportDialog component imports
Configuration:
- Enable deep link plugin in tauri.conf.json
- Update Cargo.lock for new dependencies
Localization:
- Add Chinese translations for deep link UI (zh.json)
- Add English translations for deep link UI (en.json)
Files: 9 changed, 359 insertions(+), 18 deletions(-)
* refactor(deeplink): enhance Codex provider template generation
Align deep link import with UI preset generation logic by:
- Adding complete config.toml template matching frontend defaults
- Generating safe provider name from sanitized input
- Including model_provider, reasoning_effort, and wire_api settings
- Removing minimal template that only contained base_url
- Cleaning up deprecated test file deeplink-test.html
* style: fix clippy uninlined_format_args warnings
Apply clippy --fix to use inline format arguments in:
- src/mcp.rs (8 fixes)
- src/services/env_manager.rs (10 fixes)
* style: apply code formatting and cleanup
- Format TypeScript files with Prettier (App.tsx, EnvWarningBanner.tsx, formatters.ts)
- Organize Rust imports and module order alphabetically
- Add newline at end of JSON files (en.json, zh.json)
- Update Cargo.lock for dependency changes
* feat: add model name configuration support for Codex and fix Gemini model handling
- Add visual model name input field for Codex providers
- Add model name extraction and update utilities in providerConfigUtils
- Implement model name state management in useCodexConfigState hook
- Add conditional model field rendering in CodexFormFields (non-official only)
- Integrate model name sync with TOML config in ProviderForm
- Fix Gemini deeplink model injection bug
- Correct environment variable name from GOOGLE_GEMINI_MODEL to GEMINI_MODEL
- Add test cases for Gemini model injection (with/without model)
- All tests passing (9/9)
- Fix Gemini model field binding in edit mode
- Add geminiModel state to useGeminiConfigState hook
- Extract model value during initialization and reset
- Sync model field with geminiEnv state to prevent data loss on submit
- Fix missing model value display when editing Gemini providers
Changes:
- 6 files changed, 245 insertions(+), 13 deletions(-)
---
deplink.html | 551 ++++++++++++++++++
src-tauri/Cargo.lock | 111 ++++
src-tauri/Cargo.toml | 4 +-
src-tauri/Info.plist | 19 +
src-tauri/src/commands/config.rs | 10 +-
src-tauri/src/commands/deeplink.rs | 29 +
src-tauri/src/commands/env.rs | 4 +-
src-tauri/src/commands/mod.rs | 2 +
src-tauri/src/deeplink.rs | 457 +++++++++++++++
src-tauri/src/lib.rs | 201 ++++++-
src-tauri/src/mcp.rs | 16 +-
src-tauri/src/provider.rs | 4 +
src-tauri/src/services/env_checker.rs | 9 +-
src-tauri/src/services/env_manager.rs | 34 +-
src-tauri/tauri.conf.json | 14 +-
src-tauri/tests/deeplink_import.rs | 121 ++++
src/App.tsx | 21 +-
src/components/DeepLinkImportDialog.tsx | 204 +++++++
src/components/env/EnvWarningBanner.tsx | 7 +-
src/components/mcp/McpFormModal.tsx | 9 +-
src/components/mcp/McpWizardModal.tsx | 4 +-
src/components/mcp/useMcpValidation.ts | 5 +-
.../providers/AddProviderDialog.tsx | 1 +
.../providers/EditProviderDialog.tsx | 2 +
src/components/providers/ProviderCard.tsx | 31 +-
.../providers/forms/BasicFormFields.tsx | 14 +
.../providers/forms/CodexFormFields.tsx | 35 ++
.../providers/forms/ProviderForm.tsx | 24 +-
.../forms/ProviderPresetSelector.tsx | 3 -
.../forms/hooks/useCodexConfigState.ts | 60 +-
.../forms/hooks/useGeminiConfigState.ts | 23 +-
src/i18n/locales/en.json | 21 +-
src/i18n/locales/zh.json | 21 +-
src/lib/api/deeplink.ts | 35 ++
src/lib/schemas/provider.ts | 1 +
src/types.ts | 2 +
src/utils/formatters.ts | 2 +-
src/utils/providerConfigUtils.ts | 63 ++
tests/components/McpFormModal.test.tsx | 4 +-
39 files changed, 2097 insertions(+), 81 deletions(-)
create mode 100644 deplink.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/deplink.html b/deplink.html
new file mode 100644
index 0000000..5fe2c5d
--- /dev/null
+++ b/deplink.html
@@ -0,0 +1,551 @@
+
+
+
+
+
+ CC Switch 深链接测试
+
+
+
+
+
+
+
+
+
+
Claude Code 供应商
+
+
+
+ Claude
+ Claude Official (官方)
+
+
+ 导入 Claude 官方 API 配置。使用官方端点 api.anthropic.com,默认模型 claude-haiku-4.1。
+
+
+ 📥 导入 Claude Official
+
+
+
+
+
+ Claude
+ Claude 测试环境
+
+
+ 公司内部测试环境配置示例。包含备注信息,方便区分不同环境。默认模型 claude-haiku-4.1。
+
+
+ 📥 导入测试环境
+
+
+
+
+
+
+
Codex 供应商
+
+
+
+ Codex
+ OpenAI Official (官方)
+
+
+ 导入 OpenAI 官方 API 配置。使用官方端点 api.openai.com,默认模型 gpt-5.1。
+
+
+ 📥 导入 OpenAI Official
+
+
+
+
+
+ Codex
+ Azure OpenAI
+
+
+ Azure 部署的 OpenAI 服务示例。适合企业用户使用 Azure 云服务。默认模型 gpt-5.1。
+
+
+ 📥 导入 Azure OpenAI
+
+
+
+
+
+
+
Gemini 供应商
+
+
+
+ Gemini
+ Google Gemini Official
+
+
+ 导入 Google Gemini 官方 API 配置。默认模型 gemini-3-pro-preview。
+
+
+ 📥 导入 Google Gemini
+
+
+
+
+
+ Gemini
+ Gemini 测试环境
+
+
+ 公司内部 Gemini 测试环境配置示例。用于验证 Gemini 相关深链接导入流程,请求地址为:https://api-gemini-test.company.com/v1beta。默认模型 gemini-3-pro-preview。
+
+
+ 📥 导入 Gemini 测试环境
+
+
+
+
+
+
+
⚠️ 使用注意事项
+
+ 首次点击 :浏览器会询问是否允许打开 CC Switch,请点击"允许"或"打开"
+ macOS 用户 :可能需要在"系统设置" → "隐私与安全性"中允许应用
+ 测试 API Key :示例中的 API Key 仅用于测试格式,无法实际使用
+ 导入确认 :点击链接后会弹出确认对话框,API Key 会被掩码显示(前4位+****)
+ 编辑配置 :导入后可以在 CC Switch 中随时编辑或删除配置
+
+
+
+
+
+
🛠️ 深链接生成器
+
填写下方表单,生成您自己的深链接
+
+
+ 应用类型 *
+
+ Claude Code
+ Codex
+ Gemini
+
+
+
+
+ 供应商名称 *
+
+
+
+
+ 官网地址 *
+
+
+
+
+ API 端点 *
+
+
+
+
+ API Key *
+
+
+
+
+ 模型(可选)
+
+
+
+
+ 备注(可选)
+
+
+
+
🚀 生成深链接
+
+
+
+
+
+
+
+
+
+
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 0663cc8..12a3653 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -613,6 +613,7 @@ dependencies = [
"serial_test",
"tauri",
"tauri-build",
+ "tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
@@ -625,6 +626,7 @@ dependencies = [
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
+ "url",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -711,6 +713,26 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.16",
+ "once_cell",
+ "tiny-keccak",
+]
+
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -821,6 +843,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -1057,6 +1085,15 @@ dependencies = [
"syn 2.0.106",
]
+[[package]]
+name = "dlv-list"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
+dependencies = [
+ "const-random",
+]
+
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1745,6 +1782,12 @@ dependencies = [
"ahash",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.16.0"
@@ -1830,6 +1873,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -2901,6 +2950,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+[[package]]
+name = "ordered-multimap"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
+dependencies = [
+ "dlv-list",
+ "hashbrown 0.14.5",
+]
+
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3756,6 +3815,16 @@ dependencies = [
"cc",
]
+[[package]]
+name = "rust-ini"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
+dependencies = [
+ "cfg-if",
+ "ordered-multimap",
+]
+
[[package]]
name = "rust_decimal"
version = "1.38.0"
@@ -4519,6 +4588,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
+ "http-range",
"jni",
"libc",
"log",
@@ -4634,6 +4704,27 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "tauri-plugin-deep-link"
+version = "2.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
+dependencies = [
+ "dunce",
+ "plist",
+ "rust-ini",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.17",
+ "tracing",
+ "url",
+ "windows-registry",
+ "windows-result 0.3.4",
+]
+
[[package]]
name = "tauri-plugin-dialog"
version = "2.4.0"
@@ -4988,6 +5079,15 @@ dependencies = [
"time-core",
]
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -5917,6 +6017,17 @@ dependencies = [
"windows-link 0.1.3",
]
+[[package]]
+name = "windows-registry"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
+dependencies = [
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
[[package]]
name = "windows-result"
version = "0.3.4"
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/env.rs b/src-tauri/src/commands/env.rs
index e1b31a8..8cf9df9 100644
--- a/src-tauri/src/commands/env.rs
+++ b/src-tauri/src/commands/env.rs
@@ -1,5 +1,7 @@
use crate::services::env_checker::{check_env_conflicts as check_conflicts, EnvConflict};
-use crate::services::env_manager::{delete_env_vars as delete_vars, restore_from_backup, BackupInfo};
+use crate::services::env_manager::{
+ delete_env_vars as delete_vars, restore_from_backup, BackupInfo,
+};
/// Check environment variable conflicts for a specific app
#[tauri::command]
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 837d9af..a98da44 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -1,6 +1,7 @@
#![allow(non_snake_case)]
mod config;
+mod deeplink;
mod env;
mod import_export;
mod mcp;
@@ -12,6 +13,7 @@ mod settings;
pub mod skill;
pub use config::*;
+pub use deeplink::*;
pub use env::*;
pub use import_export::*;
pub use mcp::*;
diff --git a/src-tauri/src/deeplink.rs b/src-tauri/src/deeplink.rs
new file mode 100644
index 0000000..b6d062f
--- /dev/null
+++ b/src-tauri/src/deeplink.rs
@@ -0,0 +1,457 @@
+/// 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("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"));
+ }
+
+ #[test]
+ fn test_build_gemini_provider_with_model() {
+ let request = DeepLinkImportRequest {
+ version: "v1".to_string(),
+ resource: "provider".to_string(),
+ app: "gemini".to_string(),
+ name: "Test Gemini".to_string(),
+ homepage: "https://example.com".to_string(),
+ endpoint: "https://api.example.com".to_string(),
+ api_key: "test-api-key".to_string(),
+ model: Some("gemini-2.0-flash".to_string()),
+ notes: None,
+ };
+
+ let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
+
+ // Verify provider basic info
+ assert_eq!(provider.name, "Test Gemini");
+ assert_eq!(
+ provider.website_url,
+ Some("https://example.com".to_string())
+ );
+
+ // Verify settings_config structure
+ let env = provider.settings_config["env"].as_object().unwrap();
+ assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
+ assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
+ assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
+ }
+
+ #[test]
+ fn test_build_gemini_provider_without_model() {
+ let request = DeepLinkImportRequest {
+ version: "v1".to_string(),
+ resource: "provider".to_string(),
+ app: "gemini".to_string(),
+ name: "Test Gemini".to_string(),
+ homepage: "https://example.com".to_string(),
+ endpoint: "https://api.example.com".to_string(),
+ api_key: "test-api-key".to_string(),
+ model: None,
+ notes: None,
+ };
+
+ let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
+
+ // Verify settings_config structure
+ let env = provider.settings_config["env"].as_object().unwrap();
+ assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
+ assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
+ // Model should not be present
+ assert!(env.get("GEMINI_MODEL").is_none());
+ }
+}
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/mcp.rs b/src-tauri/src/mcp.rs
index 61a386e..8940ebc 100644
--- a/src-tauri/src/mcp.rs
+++ b/src-tauri/src/mcp.rs
@@ -536,7 +536,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result
if !json_arr.is_empty() {
Some(serde_json::Value::Array(json_arr))
} else {
- log::debug!("跳过复杂数组字段 '{}' (TOML → JSON)", key);
+ log::debug!("跳过复杂数组字段 '{key}' (TOML → JSON)");
None
}
}
@@ -551,19 +551,19 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result
if !json_obj.is_empty() {
Some(serde_json::Value::Object(json_obj))
} else {
- log::debug!("跳过复杂对象字段 '{}' (TOML → JSON)", key);
+ log::debug!("跳过复杂对象字段 '{key}' (TOML → JSON)");
None
}
}
toml::Value::Datetime(_) => {
- log::debug!("跳过日期时间字段 '{}' (TOML → JSON)", key);
+ log::debug!("跳过日期时间字段 '{key}' (TOML → JSON)");
None
}
};
if let Some(val) = json_val {
spec.insert(key.clone(), val);
- log::debug!("导入扩展字段 '{}' = {:?}", key, toml_val);
+ log::debug!("导入扩展字段 '{key}' = {toml_val:?}");
}
}
@@ -831,7 +831,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option Result
// 记录扩展字段的处理
if extended_fields.contains(&key.as_str()) {
- log::debug!("已转换扩展字段 '{}' = {:?}", key, value);
+ log::debug!("已转换扩展字段 '{key}' = {value:?}");
} else {
- log::info!("已转换自定义字段 '{}' = {:?}", key, value);
+ log::info!("已转换自定义字段 '{key}' = {value:?}");
}
}
}
@@ -1094,7 +1094,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
if servers.remove(id).is_some() {
- log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{}'", id);
+ log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}
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/src/services/env_checker.rs b/src-tauri/src/services/env_checker.rs
index 1fe42a2..842bc3e 100644
--- a/src-tauri/src/services/env_checker.rs
+++ b/src-tauri/src/services/env_checker.rs
@@ -124,7 +124,9 @@ fn check_shell_configs(keywords: &[&str]) -> Result, String> {
let trimmed = line.trim();
// Match patterns like: export VAR=value or VAR=value
- if trimmed.starts_with("export ") || (!trimmed.starts_with('#') && trimmed.contains('=')) {
+ if trimmed.starts_with("export ")
+ || (!trimmed.starts_with('#') && trimmed.contains('='))
+ {
let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed);
if let Some(eq_pos) = export_line.find('=') {
@@ -135,7 +137,10 @@ fn check_shell_configs(keywords: &[&str]) -> Result, String> {
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: var_name.to_string(),
- var_value: var_value.trim_matches('"').trim_matches('\'').to_string(),
+ var_value: var_value
+ .trim_matches('"')
+ .trim_matches('\'')
+ .to_string(),
source_type: "file".to_string(),
source_path: format!("{}:{}", file_path, line_num + 1),
});
diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs
index ffb8aa2..0ee46da 100644
--- a/src-tauri/src/services/env_manager.rs
+++ b/src-tauri/src/services/env_manager.rs
@@ -43,11 +43,11 @@ pub fn delete_env_vars(conflicts: Vec) -> Result Result {
// Get backup directory
let backup_dir = get_backup_dir()?;
- fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {}", e))?;
+ fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?;
// Generate backup file name with timestamp
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
- let backup_file = backup_dir.join(format!("env-backup-{}.json", timestamp));
+ let backup_file = backup_dir.join(format!("env-backup-{timestamp}.json"));
// Create backup data
let backup_info = BackupInfo {
@@ -58,9 +58,9 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result {
// Write backup file
let json = serde_json::to_string_pretty(&backup_info)
- .map_err(|e| format!("序列化备份数据失败: {}", e))?;
+ .map_err(|e| format!("序列化备份数据失败: {e}"))?;
- fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {}", e))?;
+ fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?;
Ok(backup_info)
}
@@ -115,7 +115,7 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
// Read file content
let content = fs::read_to_string(file_path)
- .map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?;
+ .map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
// Filter out the line containing the environment variable
let new_content: Vec = content
@@ -137,7 +137,7 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
// Write back to file
fs::write(file_path, new_content.join("\n"))
- .map_err(|e| format!("写入文件失败 {}: {}", file_path, e))?;
+ .map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
@@ -152,11 +152,10 @@ fn delete_single_env(conflict: &EnvConflict) -> Result<(), String> {
/// Restore environment variables from backup
pub fn restore_from_backup(backup_path: String) -> Result<(), String> {
// Read backup file
- let content =
- fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {}", e))?;
+ let content = fs::read_to_string(&backup_path).map_err(|e| format!("读取备份文件失败: {e}"))?;
- let backup_info: BackupInfo = serde_json::from_str(&content)
- .map_err(|e| format!("解析备份文件失败: {}", e))?;
+ let backup_info: BackupInfo =
+ serde_json::from_str(&content).map_err(|e| format!("解析备份文件失败: {e}"))?;
// Restore each variable
for conflict in &backup_info.conflicts {
@@ -190,7 +189,10 @@ fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
}
Ok(())
}
- _ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)),
+ _ => Err(format!(
+ "无法恢复类型为 {} 的环境变量",
+ conflict.source_type
+ )),
}
}
@@ -208,19 +210,21 @@ fn restore_single_env(conflict: &EnvConflict) -> Result<(), String> {
// Read file content
let mut content = fs::read_to_string(file_path)
- .map_err(|e| format!("读取文件失败 {}: {}", file_path, e))?;
+ .map_err(|e| format!("读取文件失败 {file_path}: {e}"))?;
// Append the environment variable line
let export_line = format!("\nexport {}={}", conflict.var_name, conflict.var_value);
content.push_str(&export_line);
// Write back to file
- fs::write(file_path, content)
- .map_err(|e| format!("写入文件失败 {}: {}", file_path, e))?;
+ fs::write(file_path, content).map_err(|e| format!("写入文件失败 {file_path}: {e}"))?;
Ok(())
}
- _ => Err(format!("无法恢复类型为 {} 的环境变量", conflict.source_type)),
+ _ => Err(format!(
+ "无法恢复类型为 {} 的环境变量",
+ conflict.source_type
+ )),
}
}
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..6402605 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,
@@ -100,7 +101,10 @@ function App() {
setShowEnvBanner(true);
}
} catch (error) {
- console.error("[App] Failed to check environment conflicts on startup:", error);
+ console.error(
+ "[App] Failed to check environment conflicts on startup:",
+ error,
+ );
}
};
@@ -117,17 +121,20 @@ function App() {
// 合并新检测到的冲突
setEnvConflicts((prev) => {
const existingKeys = new Set(
- prev.map((c) => `${c.varName}:${c.sourcePath}`)
+ prev.map((c) => `${c.varName}:${c.sourcePath}`),
);
const newConflicts = conflicts.filter(
- (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`)
+ (c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
);
return [...prev, ...newConflicts];
});
setShowEnvBanner(true);
}
} catch (error) {
- console.error("[App] Failed to check environment conflicts on app switch:", error);
+ console.error(
+ "[App] Failed to check environment conflicts on app switch:",
+ error,
+ );
}
};
@@ -239,7 +246,10 @@ function App() {
setShowEnvBanner(false);
}
} catch (error) {
- console.error("[App] Failed to re-check conflicts after deletion:", error);
+ console.error(
+ "[App] Failed to re-check conflicts after deletion:",
+ error,
+ );
}
}}
/>
@@ -402,6 +412,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")}
+
+
+
+
+
+ {t("common.cancel")}
+
+
+ {isImporting ? t("deeplink.importing") : t("deeplink.import")}
+
+
+
+
+ );
+}
diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx
index 3ae21d0..76a167f 100644
--- a/src/components/env/EnvWarningBanner.tsx
+++ b/src/components/env/EnvWarningBanner.tsx
@@ -198,7 +198,8 @@ export function EnvWarningBanner({
{t("env.field.value")}: {conflict.varValue}
- {t("env.field.source")}: {getSourceDescription(conflict)}
+ {t("env.field.source")}:{" "}
+ {getSourceDescription(conflict)}
@@ -247,7 +248,9 @@ export function EnvWarningBanner({
{t("env.confirm.title")}
- {t("env.confirm.message", { count: selectedConflicts.size })}
+
+ {t("env.confirm.message", { count: selectedConflicts.size })}
+
{t("env.confirm.backupNotice")}
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({
{displayUrl}
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/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx
index 400642a..fefe41c 100644
--- a/src/components/providers/forms/CodexFormFields.tsx
+++ b/src/components/providers/forms/CodexFormFields.tsx
@@ -26,6 +26,11 @@ interface CodexFormFieldsProps {
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
+ // Model Name
+ shouldShowModelField?: boolean;
+ modelName?: string;
+ onModelNameChange?: (model: string) => void;
+
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
}
@@ -45,6 +50,9 @@ export function CodexFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
+ shouldShowModelField = true,
+ modelName = "",
+ onModelNameChange,
speedTestEndpoints,
}: CodexFormFieldsProps) {
const { t } = useTranslation();
@@ -85,6 +93,33 @@ export function CodexFormFields({
/>
)}
+ {/* Codex Model Name 输入框 */}
+ {shouldShowModelField && onModelNameChange && (
+
+
+ {t("codexConfig.modelName", { defaultValue: "模型名称" })}
+
+
onModelNameChange(e.target.value)}
+ placeholder={t("codexConfig.modelNamePlaceholder", {
+ defaultValue: "例如: gpt-5-codex",
+ })}
+ className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
+ />
+
+ {t("codexConfig.modelNameHint", {
+ defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
+ })}
+
+
+ )}
+
{/* 端点测速弹窗 - Codex */}
{shouldShowSpeedTest && isEndpointModalOpen && (
;
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"
@@ -200,10 +202,12 @@ export function ProviderForm({
codexConfig,
codexApiKey,
codexBaseUrl,
+ codexModelName,
codexAuthError,
setCodexAuth,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
+ handleCodexModelNameChange,
handleCodexConfigChange: originalHandleCodexConfigChange,
resetCodexConfig,
} = useCodexConfigState({ initialData });
@@ -313,12 +317,14 @@ export function ProviderForm({
const {
geminiEnv,
geminiConfig,
+ geminiModel,
envError,
configError: geminiConfigError,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
+ envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
@@ -621,7 +627,6 @@ export function ProviderForm({
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
category={category}
- appId={appId}
/>
)}
@@ -684,6 +689,9 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
+ shouldShowModelField={category !== "official"}
+ modelName={codexModelName}
+ onModelNameChange={handleCodexModelNameChange}
speedTestEndpoints={speedTestEndpoints}
/>
)}
@@ -710,17 +718,19 @@ export function ProviderForm({
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
shouldShowModelField={true}
- model={
- form.watch("settingsConfig")
- ? JSON.parse(form.watch("settingsConfig") || "{}")?.env
- ?.GEMINI_MODEL || ""
- : ""
- }
+ model={geminiModel}
onModelChange={(model) => {
+ // 同时更新 form.settingsConfig 和 geminiEnv
const config = JSON.parse(form.watch("settingsConfig") || "{}");
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
+
+ // 同步更新 geminiEnv,确保提交时不丢失
+ const envObj = envStringToObj(geminiEnv);
+ envObj.GEMINI_MODEL = model.trim();
+ const newEnv = envObjToString(envObj);
+ handleGeminiEnvChange(newEnv);
}}
speedTestEndpoints={speedTestEndpoints}
/>
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/components/providers/forms/hooks/useCodexConfigState.ts b/src/components/providers/forms/hooks/useCodexConfigState.ts
index 5f2823e..60436a0 100644
--- a/src/components/providers/forms/hooks/useCodexConfigState.ts
+++ b/src/components/providers/forms/hooks/useCodexConfigState.ts
@@ -2,6 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
import {
extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig,
+ extractCodexModelName,
+ setCodexModelName as setCodexModelNameInConfig,
} from "@/utils/providerConfigUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
@@ -20,9 +22,11 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
const [codexBaseUrl, setCodexBaseUrl] = useState("");
+ const [codexModelName, setCodexModelName] = useState("");
const [codexAuthError, setCodexAuthError] = useState("");
const isUpdatingCodexBaseUrlRef = useRef(false);
+ const isUpdatingCodexModelNameRef = useRef(false);
// 初始化 Codex 配置(编辑模式)
useEffect(() => {
@@ -47,6 +51,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(initialBaseUrl);
}
+ // 提取 Model Name
+ const initialModelName = extractCodexModelName(configStr);
+ if (initialModelName) {
+ setCodexModelName(initialModelName);
+ }
+
// 提取 API Key
try {
if (auth && typeof auth.OPENAI_API_KEY === "string") {
@@ -69,6 +79,17 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
}
}, [codexConfig, codexBaseUrl]);
+ // 与 TOML 配置保持模型名称同步
+ useEffect(() => {
+ if (isUpdatingCodexModelNameRef.current) {
+ return;
+ }
+ const extracted = extractCodexModelName(codexConfig) || "";
+ if (extracted !== codexModelName) {
+ setCodexModelName(extracted);
+ }
+ }, [codexConfig, codexModelName]);
+
// 获取 API Key(从 auth JSON)
const getCodexAuthApiKey = useCallback((authString: string): string => {
try {
@@ -157,7 +178,26 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
[setCodexConfig],
);
- // 处理 config 变化(同步 Base URL)
+ // 处理 Codex Model Name 变化
+ const handleCodexModelNameChange = useCallback(
+ (modelName: string) => {
+ const trimmed = modelName.trim();
+ setCodexModelName(trimmed);
+
+ if (!trimmed) {
+ return;
+ }
+
+ isUpdatingCodexModelNameRef.current = true;
+ setCodexConfig((prev) => setCodexModelNameInConfig(prev, trimmed));
+ setTimeout(() => {
+ isUpdatingCodexModelNameRef.current = false;
+ }, 0);
+ },
+ [setCodexConfig],
+ );
+
+ // 处理 config 变化(同步 Base URL 和 Model Name)
const handleCodexConfigChange = useCallback(
(value: string) => {
// 归一化中文/全角/弯引号,避免 TOML 解析报错
@@ -170,8 +210,15 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(extracted);
}
}
+
+ if (!isUpdatingCodexModelNameRef.current) {
+ const extractedModel = extractCodexModelName(normalized) || "";
+ if (extractedModel !== codexModelName) {
+ setCodexModelName(extractedModel);
+ }
+ }
},
- [setCodexConfig, codexBaseUrl],
+ [setCodexConfig, codexBaseUrl, codexModelName],
);
// 重置配置(用于预设切换)
@@ -186,6 +233,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
setCodexBaseUrl(baseUrl);
}
+ const modelName = extractCodexModelName(config);
+ if (modelName) {
+ setCodexModelName(modelName);
+ } else {
+ setCodexModelName("");
+ }
+
// 提取 API Key
try {
if (auth && typeof auth.OPENAI_API_KEY === "string") {
@@ -205,11 +259,13 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
codexConfig,
codexApiKey,
codexBaseUrl,
+ codexModelName,
codexAuthError,
setCodexAuth,
setCodexConfig,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
+ handleCodexModelNameChange,
handleCodexConfigChange,
resetCodexConfig,
getCodexAuthApiKey,
diff --git a/src/components/providers/forms/hooks/useGeminiConfigState.ts b/src/components/providers/forms/hooks/useGeminiConfigState.ts
index 4ab96e6..cad1220 100644
--- a/src/components/providers/forms/hooks/useGeminiConfigState.ts
+++ b/src/components/providers/forms/hooks/useGeminiConfigState.ts
@@ -17,6 +17,7 @@ export function useGeminiConfigState({
const [geminiConfig, setGeminiConfigState] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
+ const [geminiModel, setGeminiModel] = useState("");
const [envError, setEnvError] = useState("");
const [configError, setConfigError] = useState("");
@@ -72,21 +73,25 @@ export function useGeminiConfigState({
const configObj = (config as any).config || {};
setGeminiConfigState(JSON.stringify(configObj, null, 2));
- // 提取 API Key 和 Base URL
+ // 提取 API Key、Base URL 和 Model
if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY);
}
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
}
+ if (typeof env.GEMINI_MODEL === "string") {
+ setGeminiModel(env.GEMINI_MODEL);
+ }
}
}, [initialData, envObjToString]);
- // 从 geminiEnv 中提取并同步 API Key 和 Base URL
+ // 从 geminiEnv 中提取并同步 API Key、Base URL 和 Model
useEffect(() => {
const envObj = envStringToObj(geminiEnv);
const extractedKey = envObj.GEMINI_API_KEY || "";
const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || "";
+ const extractedModel = envObj.GEMINI_MODEL || "";
if (extractedKey !== geminiApiKey) {
setGeminiApiKey(extractedKey);
@@ -94,7 +99,10 @@ export function useGeminiConfigState({
if (extractedBaseUrl !== geminiBaseUrl) {
setGeminiBaseUrl(extractedBaseUrl);
}
- }, [geminiEnv, envStringToObj]);
+ if (extractedModel !== geminiModel) {
+ setGeminiModel(extractedModel);
+ }
+ }, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
// 验证 Gemini Config JSON
const validateGeminiConfig = useCallback((value: string): string => {
@@ -181,7 +189,7 @@ export function useGeminiConfigState({
setGeminiEnv(envString);
setGeminiConfig(configString);
- // 提取 API Key 和 Base URL
+ // 提取 API Key、Base URL 和 Model
if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY);
} else {
@@ -193,6 +201,12 @@ export function useGeminiConfigState({
} else {
setGeminiBaseUrl("");
}
+
+ if (typeof env.GEMINI_MODEL === "string") {
+ setGeminiModel(env.GEMINI_MODEL);
+ } else {
+ setGeminiModel("");
+ }
},
[envObjToString, setGeminiEnv, setGeminiConfig],
);
@@ -202,6 +216,7 @@ export function useGeminiConfigState({
geminiConfig,
geminiApiKey,
geminiBaseUrl,
+ geminiModel,
envError,
configError,
setGeminiEnv,
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 779301f..31c8068 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",
@@ -690,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"
}
}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index e2fb059..19905a0 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": "自定义",
@@ -690,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": "导入失败"
}
}
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/src/utils/formatters.ts b/src/utils/formatters.ts
index 70e3718..0f5a52c 100644
--- a/src/utils/formatters.ts
+++ b/src/utils/formatters.ts
@@ -35,7 +35,7 @@ export function parseSmartMcpJson(jsonText: string): {
}
// 如果是键值对片段("key": {...}),包装成完整对象
- if (trimmed.startsWith('"') && !trimmed.startsWith('{')) {
+ if (trimmed.startsWith('"') && !trimmed.startsWith("{")) {
trimmed = `{${trimmed}}`;
}
diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts
index 7973b31..e2e73a1 100644
--- a/src/utils/providerConfigUtils.ts
+++ b/src/utils/providerConfigUtils.ts
@@ -467,3 +467,66 @@ export const setCodexBaseUrl = (
: normalizedText;
return `${prefix}${replacementLine}\n`;
};
+
+// ========== Codex model name utils ==========
+
+// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)
+export const extractCodexModelName = (
+ configText: string | undefined | null,
+): string | undefined => {
+ try {
+ const raw = typeof configText === "string" ? configText : "";
+ // 归一化中文/全角引号,避免正则提取失败
+ const text = normalizeQuotes(raw);
+ if (!text) return undefined;
+
+ // 匹配 model = "xxx" 或 model = 'xxx'
+ const m = text.match(/^model\s*=\s*(['"])([^'"]+)\1/m);
+ return m && m[2] ? m[2] : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+// 在 Codex 的 TOML 配置文本中写入或更新 model 字段
+export const setCodexModelName = (
+ configText: string,
+ modelName: string,
+): string => {
+ const trimmed = modelName.trim();
+ if (!trimmed) {
+ return configText;
+ }
+
+ // 归一化原文本中的引号(既能匹配,也能输出稳定格式)
+ const normalizedText = normalizeQuotes(configText);
+
+ const replacementLine = `model = "${trimmed}"`;
+ const pattern = /^model\s*=\s*["']([^"']+)["']/m;
+
+ if (pattern.test(normalizedText)) {
+ return normalizedText.replace(pattern, replacementLine);
+ }
+
+ // 如果不存在 model 字段,尝试在 model_provider 之后插入
+ // 如果 model_provider 也不存在,则插入到开头
+ const providerPattern = /^model_provider\s*=\s*["'][^"']+["']/m;
+ const match = normalizedText.match(providerPattern);
+
+ if (match && match.index !== undefined) {
+ // 在 model_provider 行之后插入
+ const endOfLine = normalizedText.indexOf("\n", match.index);
+ if (endOfLine !== -1) {
+ return (
+ normalizedText.slice(0, endOfLine + 1) +
+ replacementLine +
+ "\n" +
+ normalizedText.slice(endOfLine + 1)
+ );
+ }
+ }
+
+ // 在文件开头插入
+ const lines = normalizedText.split("\n");
+ return `${replacementLine}\n${lines.join("\n")}`;
+};
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"), {