From 956e72378192ef48d97b5510a2948c704e58a389 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 18 Nov 2025 02:06:10 +0800 Subject: [PATCH] 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(-) --- src-tauri/Cargo.lock | 111 +++++++++++++ src-tauri/Cargo.toml | 4 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 201 ++++++++++++++++++++++-- src-tauri/tauri.conf.json | 14 +- src/App.tsx | 3 + src/components/DeepLinkImportDialog.tsx | 6 +- src/i18n/locales/en.json | 18 +++ src/i18n/locales/zh.json | 18 +++ 9 files changed, 359 insertions(+), 18 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d966f0a..3564351 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -579,6 +579,7 @@ dependencies = [ "serial_test", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", @@ -591,6 +592,7 @@ dependencies = [ "tokio", "toml 0.8.2", "toml_edit 0.22.27", + "url", ] [[package]] @@ -665,6 +667,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 = "convert_case" version = "0.4.0" @@ -754,6 +776,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" @@ -983,6 +1011,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" @@ -1671,6 +1708,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" @@ -1747,6 +1790,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" @@ -2778,6 +2827,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" @@ -3623,6 +3682,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" @@ -4362,6 +4431,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", @@ -4477,6 +4547,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" @@ -4831,6 +4922,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" @@ -5754,6 +5854,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 066182b..77656d8 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 = "0.4" -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" @@ -42,6 +43,7 @@ futures = "0.3" regex = "1.10" rquickjs = { version = "0.8", features = ["array-buffer", "classes"] } thiserror = "1.0" +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/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 90d7516..ab99fca 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 import_export; mod mcp; mod misc; @@ -10,6 +11,7 @@ mod provider; mod settings; pub use config::*; +pub use deeplink::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e8852e..2f9def5 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, @@ -35,6 +37,7 @@ pub use services::{ }; pub use settings::{update_settings, AppSettings}; pub use store::AppState; +use tauri_plugin_deep_link::DeepLinkExt; use tauri::{ menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}, @@ -281,6 +284,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 +} + // /// 内部切换供应商函数 @@ -346,7 +408,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(); @@ -356,6 +438,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 { @@ -471,7 +555,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)?; @@ -572,6 +689,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, ]); @@ -581,17 +701,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/tauri.conf.json b/src-tauri/tauri.conf.json index a66f8d9..ec1585c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,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": { @@ -41,9 +45,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/App.tsx b/src/App.tsx index d15b0de..45c5857 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { UpdateBadge } from "@/components/UpdateBadge"; import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; +import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { Button } from "@/components/ui/button"; function App() { @@ -303,6 +304,8 @@ function App() { /> + + ); } diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 0c21a1c..49f9f65 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -113,10 +113,8 @@ export function DeepLinkImportDialog() {
{t("deeplink.app")}
-
- - {request.app} - +
+ {request.app}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 39cc560..ab6a9e2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -608,5 +608,23 @@ "deleteTitle": "Confirm Delete", "deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?" } + }, + "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 d998818..da43db7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -608,5 +608,23 @@ "deleteTitle": "确认删除", "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" } + }, + "deeplink": { + "confirmImport": "确认导入供应商配置", + "confirmImportDescription": "以下配置将导入到 CC Switch", + "app": "应用类型", + "providerName": "供应商名称", + "homepage": "官网地址", + "endpoint": "API 端点", + "apiKey": "API 密钥", + "model": "模型", + "notes": "备注", + "import": "导入", + "importing": "导入中...", + "warning": "请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。", + "parseError": "深链接解析失败", + "importSuccess": "导入成功", + "importSuccessDescription": "供应商 \"{{name}}\" 已成功导入", + "importError": "导入失败" } }