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(-)
This commit is contained in:
YoVinchen
2025-11-19 09:03:18 +08:00
committed by GitHub
parent 0ae9ed5a17
commit 3d69da5b66
39 changed files with 2097 additions and 81 deletions

111
src-tauri/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

19
src-tauri/Info.plist Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 注册 ccswitch:// 自定义 URL 协议,用于深链接导入 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>CC Switch Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ccswitch</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -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::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {}", e))?;
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
AppType::Codex => {
// TOML 格式暂不验证(或可使用 toml crate

View File

@@ -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<DeepLinkImportRequest, String> {
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<AppState>,
request: DeepLinkImportRequest,
) -> Result<String, String> {
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)
}

View File

@@ -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]

View File

@@ -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::*;

457
src-tauri/src/deeplink.rs Normal file
View File

@@ -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<String>,
/// Optional notes/description
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
/// 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<DeepLinkImportRequest, AppError> {
// 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<String, String> = 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<String, AppError> {
// 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::<String>()
.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<Provider, AppError> {
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&notes=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());
}
}

View File

@@ -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();
}
}
}
}
_ => {}
}
}

View File

@@ -536,7 +536,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
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<usize, AppError>
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<toml_edit:
} else if let Some(f) = n.as_f64() {
Some(toml_edit::value(f))
} else {
log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {}", n);
log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {n}");
None
}
}
@@ -1009,9 +1009,9 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
// 记录扩展字段的处理
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}'");
}
}
}

View File

@@ -22,6 +22,9 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
/// 备注信息
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
@@ -43,6 +46,7 @@ impl Provider {
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
}
}

View File

@@ -124,7 +124,9 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, 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<Vec<EnvConflict>, 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),
});

View File

@@ -43,11 +43,11 @@ pub fn delete_env_vars(conflicts: Vec<EnvConflict>) -> Result<BackupInfo, String
fn create_backup(conflicts: &[EnvConflict]) -> Result<BackupInfo, String> {
// 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<BackupInfo, String> {
// 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<String> = 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
)),
}
}

View File

@@ -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": [

View File

@@ -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"
);
}