Merge branch 'feat/add-provider-notes' into main
- Add provider notes field support - Add deeplink import functionality - Merge with environment variable conflict detection - Merge with Skills management feature - Combine tauri features: tray-icon and protocol-asset - Keep both env and deeplink modules in commands - Merge i18n translations for all new features
This commit is contained in:
@@ -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
19
src-tauri/Info.plist
Normal 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
src-tauri/src/commands/deeplink.rs
Normal file
29
src-tauri/src/commands/deeplink.rs
Normal 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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod config;
|
||||
mod env;
|
||||
mod deeplink;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
@@ -13,6 +14,7 @@ pub mod skill;
|
||||
|
||||
pub use config::*;
|
||||
pub use env::*;
|
||||
pub use deeplink::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
|
||||
407
src-tauri/src/deeplink.rs
Normal file
407
src-tauri/src/deeplink.rs
Normal file
@@ -0,0 +1,407 @@
|
||||
/// Deep link import functionality for CC Switch
|
||||
///
|
||||
/// This module implements the ccswitch:// protocol for importing provider configurations
|
||||
/// via deep links. See docs/ccswitch-deeplink-design.md for detailed design.
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::ProviderService;
|
||||
use crate::store::AppState;
|
||||
use crate::AppType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use url::Url;
|
||||
|
||||
/// Deep link import request model
|
||||
/// Represents a parsed ccswitch:// URL ready for processing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeepLinkImportRequest {
|
||||
/// Protocol version (e.g., "v1")
|
||||
pub version: String,
|
||||
/// Resource type to import (e.g., "provider")
|
||||
pub resource: String,
|
||||
/// Target application (claude/codex/gemini)
|
||||
pub app: String,
|
||||
/// Provider name
|
||||
pub name: String,
|
||||
/// Provider homepage URL
|
||||
pub homepage: String,
|
||||
/// API endpoint/base URL
|
||||
pub endpoint: String,
|
||||
/// API key
|
||||
pub api_key: String,
|
||||
/// Optional model name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<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("GOOGLE_GEMINI_MODEL".to_string(), json!(model));
|
||||
}
|
||||
|
||||
json!({ "env": env })
|
||||
}
|
||||
};
|
||||
|
||||
let provider = Provider {
|
||||
id: String::new(), // Will be generated by ProviderService
|
||||
name: request.name.clone(),
|
||||
settings_config,
|
||||
website_url: Some(request.homepage.clone()),
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: request.notes.clone(),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_claude_deeplink() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(request.version, "v1");
|
||||
assert_eq!(request.resource, "provider");
|
||||
assert_eq!(request.app, "claude");
|
||||
assert_eq!(request.name, "Test Provider");
|
||||
assert_eq!(request.homepage, "https://example.com");
|
||||
assert_eq!(request.endpoint, "https://api.example.com");
|
||||
assert_eq!(request.api_key, "sk-test-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_deeplink_with_notes() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes";
|
||||
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_scheme() {
|
||||
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||||
|
||||
let result = parse_deeplink_url(url);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unsupported_version() {
|
||||
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
|
||||
|
||||
let result = parse_deeplink_url(url);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Unsupported protocol version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_required_field() {
|
||||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test";
|
||||
|
||||
let result = parse_deeplink_url(url);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Missing 'homepage' parameter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_invalid_url() {
|
||||
let result = validate_url("not-a-url", "test");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_invalid_scheme() {
|
||||
let result = validate_url("ftp://example.com", "test");
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("must be http or https"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
121
src-tauri/tests/deeplink_import.rs
Normal file
121
src-tauri/tests/deeplink_import.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user