From 8cb3c69b781d45f5178055f0120f97e00713eed7 Mon Sep 17 00:00:00 2001 From: coolcoala Date: Sat, 23 Aug 2025 03:13:24 +0300 Subject: [PATCH] Fixed issue with deep links --- src-tauri/src/cmd/app.rs | 2 + src-tauri/src/core/handle.rs | 53 ++++++++++ src-tauri/src/lib.rs | 130 +++++++++++++------------ src-tauri/src/module/lightweight.rs | 2 +- src-tauri/src/utils/init.rs | 27 +++++ src-tauri/src/utils/resolve.rs | 146 ++++++++++++++++++++++++++-- src/hooks/use-listen.ts | 3 +- 7 files changed, 290 insertions(+), 73 deletions(-) diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 4a8a4637..810b5220 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -211,6 +211,8 @@ pub fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult { pub fn notify_ui_ready() -> CmdResult<()> { log::info!(target: "app", "Frontend UI is ready"); crate::utils::resolve::mark_ui_ready(); + // Flush any pending messages queued while UI was not ready (e.g. minimized to tray) + crate::core::handle::Handle::global().flush_ui_pending_messages(); Ok(()) } diff --git a/src-tauri/src/core/handle.rs b/src-tauri/src/core/handle.rs index c2f51445..0fe5ccb4 100644 --- a/src-tauri/src/core/handle.rs +++ b/src-tauri/src/core/handle.rs @@ -258,6 +258,8 @@ pub struct Handle { startup_errors: Arc>>, startup_completed: Arc>, notification_system: Arc>>, + /// Messages that should be emitted only after UI is really ready + ui_pending_messages: Arc>>, } impl Default for Handle { @@ -268,6 +270,7 @@ impl Default for Handle { startup_errors: Arc::new(RwLock::new(Vec::new())), startup_completed: Arc::new(RwLock::new(false)), notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))), + ui_pending_messages: Arc::new(RwLock::new(Vec::new())), } } } @@ -295,6 +298,10 @@ impl Handle { } pub fn get_window(&self) -> Option { + // If we are in lightweight mode, treat as no window (webview may be destroyed) + if crate::module::lightweight::is_in_lightweight_mode() { + return None; + } let app_handle = self.app_handle()?; let window: Option = app_handle.get_webview_window("main"); if window.is_none() { @@ -411,6 +418,7 @@ impl Handle { let status_str = status.into(); let msg_str = msg.into(); + // If startup not completed, buffer messages (existing behavior) if !*handle.startup_completed.read() { logging!( info, @@ -429,6 +437,23 @@ impl Handle { return; } + // If UI is not yet ready (e.g., window re-created from tray or lightweight mode), + // buffer messages to emit after UI signals readiness. + if !crate::utils::resolve::is_ui_ready() { + log::debug!( + target: "app", + "UI not ready, queue notice message: {} - {}", + status_str, + msg_str + ); + let mut pendings = handle.ui_pending_messages.write(); + pendings.push(ErrorMessage { + status: status_str, + message: msg_str, + }); + return; + } + if handle.is_exiting() { return; } @@ -442,6 +467,34 @@ impl Handle { } } + /// Flush messages buffered while UI was not ready + pub fn flush_ui_pending_messages(&self) { + let pending = { + let mut msgs = self.ui_pending_messages.write(); + std::mem::take(&mut *msgs) + }; + + if pending.is_empty() { + return; + } + + if self.is_exiting() { + return; + } + + let system_opt = self.notification_system.read(); + if let Some(system) = system_opt.as_ref() { + for msg in pending { + system.send_event(FrontendEvent::NoticeMessage { + status: msg.status, + message: msg.message, + }); + // small pacing to avoid flooding immediately on resume + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } + pub fn mark_startup_completed(&self) { { let mut completed = self.startup_completed.write(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ba9e2c5..e0f8d9ee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,11 +7,7 @@ mod module; mod process; mod state; mod utils; -use crate::{ - core::hotkey, - process::AsyncHandler, - utils::{resolve, resolve::resolve_scheme}, -}; +use crate::{core::hotkey, process::AsyncHandler, utils::resolve}; use config::Config; use std::sync::{Mutex, Once}; use tauri::AppHandle; @@ -86,7 +82,10 @@ impl AppHandleManager { #[allow(clippy::panic)] pub fn run() { - utils::network::NetworkManager::global().init(); + // Capture early deep link before any async setup (cold start on macOS) + utils::resolve::capture_early_deep_link_from_args(); + + utils::network::NetworkManager::global().init(); let _ = utils::dirs::init_portable_flag(); @@ -96,52 +95,54 @@ pub fn run() { #[cfg(debug_assertions)] let devtools = tauri_plugin_devtools::init(); - #[allow(unused_mut)] - let mut builder = tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); - } - })) - .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_global_shortcut::Builder::new().build()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_deep_link::init()) - .setup(|app| { - logging!(info, Type::Setup, true, "Starting app initialization..."); - let mut auto_start_plugin_builder = tauri_plugin_autostart::Builder::new(); - #[cfg(target_os = "macos")] - { - auto_start_plugin_builder = auto_start_plugin_builder - .macos_launcher(MacosLauncher::LaunchAgent) - .app_name(app.config().identifier.clone()); - } - let _ = app.handle().plugin(auto_start_plugin_builder.build()); + #[allow(unused_mut)] + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| { + // Handle deep link when a second instance is invoked: forward URL to the running instance + if let Some(url) = argv + .iter() + .find(|a| a.starts_with("clash://") || a.starts_with("koala-clash://")) + .cloned() + { + // Robust scheduling avoids races with lightweight/window + resolve::schedule_handle_deep_link(url); + } + })) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_deep_link::init()) + .setup(|app| { + logging!(info, Type::Setup, true, "Starting app initialization..."); - #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] - { - use tauri_plugin_deep_link::DeepLinkExt; - logging!(info, Type::Setup, true, "Registering deep links..."); - logging_error!(Type::System, true, app.deep_link().register_all()); - } + // Register deep link handler as early as possible to not miss cold-start events (macOS) + app.deep_link().on_open_url(|event| { + let urls: Vec = event.urls().iter().map(|u| u.to_string()).collect(); + logging!(info, Type::Setup, true, "on_open_url received: {:?}", urls); + if let Some(url) = urls.first().cloned() { + resolve::schedule_handle_deep_link(url); + } + }); - app.deep_link().on_open_url(|event| { - AsyncHandler::spawn(move || { - let url = event.urls().first().map(|u| u.to_string()); - async move { - if let Some(url) = url { - logging_error!(Type::Setup, true, resolve_scheme(url).await); - } - } - }); - }); + let mut auto_start_plugin_builder = tauri_plugin_autostart::Builder::new(); + #[cfg(target_os = "macos")] + { + auto_start_plugin_builder = auto_start_plugin_builder + .macos_launcher(MacosLauncher::LaunchAgent) + .app_name(app.config().identifier.clone()); + } + let _ = app.handle().plugin(auto_start_plugin_builder.build()); + + // Ensure URL schemes are registered with the OS (all platforms) + logging!(info, Type::Setup, true, "Registering deep links with OS..."); + logging_error!(Type::System, true, app.deep_link().register_all()); + + // Deep link handler will be registered AFTER core handle init to ensure window creation works // 窗口管理 logging!( @@ -223,18 +224,23 @@ pub fn run() { app.manage(Mutex::new(state::proxy::CmdProxyState::default())); app.manage(Mutex::new(state::lightweight::LightWeightState::default())); - tauri::async_runtime::spawn(async { - tokio::time::sleep(Duration::from_secs(5)).await; - logging!( - info, - Type::Cmd, - true, - "Running profile updates at startup..." - ); - if let Err(e) = crate::cmd::update_profiles_on_startup().await { - log::error!("Failed to update profiles on startup: {e}"); - } - }); + // If an early deep link was captured from argv, schedule it now (after core and window can be created) + utils::resolve::replay_early_deep_link(); + + // (deep link handler already registered above) + + tauri::async_runtime::spawn(async { + tokio::time::sleep(Duration::from_secs(5)).await; + logging!( + info, + Type::Cmd, + true, + "Running profile updates at startup..." + ); + if let Err(e) = crate::cmd::update_profiles_on_startup().await { + log::error!("Failed to update profiles on startup: {e}"); + } + }); logging!( info, diff --git a/src-tauri/src/module/lightweight.rs b/src-tauri/src/module/lightweight.rs index 6e77e263..76b6e002 100644 --- a/src-tauri/src/module/lightweight.rs +++ b/src-tauri/src/module/lightweight.rs @@ -115,7 +115,7 @@ pub fn disable_auto_light_weight_mode() { pub fn entry_lightweight_mode() { use crate::utils::window_manager::WindowManager; - + crate::utils::resolve::reset_ui_ready(); let result = WindowManager::hide_main_window(); logging!( info, diff --git a/src-tauri/src/utils/init.rs b/src-tauri/src/utils/init.rs index 1bc2c825..b02de6b3 100644 --- a/src-tauri/src/utils/init.rs +++ b/src-tauri/src/utils/init.rs @@ -398,6 +398,33 @@ pub fn init_scheme() -> Result<()> { } #[cfg(target_os = "macos")] pub fn init_scheme() -> Result<()> { + use std::process::Command; + use tauri::utils::platform::current_exe; + + // Try to re-register the app bundle with LaunchServices to ensure URL schemes are active + if let Ok(exe) = current_exe() { + if let (Some(_parent1), Some(_parent2), Some(app_bundle)) = + (exe.parent(), exe.parent().and_then(|p| p.parent()), exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent())) + { + let app_bundle_path = app_bundle.to_string_lossy().into_owned(); + let lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + let output = Command::new(lsregister) + .args(["-f", "-R", &app_bundle_path]) + .output(); + match output { + Ok(out) => { + if !out.status.success() { + log::warn!(target: "app", "lsregister returned non-zero: {:?}", out.status); + } else { + log::info!(target: "app", "Re-registered URL schemes with LaunchServices"); + } + } + Err(e) => { + log::warn!(target: "app", "Failed to run lsregister: {e}"); + } + } + } + } Ok(()) } diff --git a/src-tauri/src/utils/resolve.rs b/src-tauri/src/utils/resolve.rs index b8f86126..70e8eff5 100644 --- a/src-tauri/src/utils/resolve.rs +++ b/src-tauri/src/utils/resolve.rs @@ -3,10 +3,11 @@ use crate::AppHandleManager; use crate::{ config::{Config, IVerge, PrfItem}, core::*, + core::handle::Handle, logging, logging_error, module::lightweight::{self, auto_lightweight_mode_init}, process::AsyncHandler, - utils::{init, logging::Type, server}, + utils::{init, logging::Type, server, window_manager::WindowManager}, wrap_err, }; use anyhow::{bail, Result}; @@ -65,6 +66,35 @@ impl Default for UiReadyState { // 获取UI就绪状态细节 static UI_READY_STATE: OnceCell> = OnceCell::new(); +// Early deep link capture on cold start +static EARLY_DEEP_LINK: OnceCell>> = OnceCell::new(); +// Deduplication for deep links to avoid processing same URL twice in short time +static LAST_DEEP_LINK: OnceCell>> = OnceCell::new(); + +fn get_early_deep_link() -> &'static Mutex> { + EARLY_DEEP_LINK.get_or_init(|| Mutex::new(None)) +} + +/// Capture deep link from process arguments as early as possible (cold start on macOS) +pub fn capture_early_deep_link_from_args() { + let args: Vec = std::env::args().collect(); + if let Some(url) = args.iter().find(|a| a.starts_with("clash://") || a.starts_with("koala-clash://")).cloned() { + println!("[DeepLink][argv] {}", url); + logging!(info, Type::Setup, true, "argv captured deep link: {}", url); + *get_early_deep_link().lock() = Some(url); + } else { + println!("[DeepLink][argv] none: {:?}", args); + logging!(info, Type::Setup, true, "no deep link found in argv at startup: {:?}", args); + } +} + +/// If an early deep link was captured before setup, schedule it now +pub fn replay_early_deep_link() { + if let Some(url) = get_early_deep_link().lock().take() { + schedule_handle_deep_link(url); + } +} + fn get_window_creating_lock() -> &'static Mutex<(bool, Instant)> { WINDOW_CREATING.get_or_init(|| Mutex::new((false, Instant::now()))) } @@ -73,6 +103,11 @@ fn get_ui_ready() -> &'static Arc> { UI_READY.get_or_init(|| Arc::new(RwLock::new(false))) } +/// Check whether the UI has finished initialization on the frontend side +pub fn is_ui_ready() -> bool { + *get_ui_ready().read() +} + fn get_ui_ready_state() -> &'static Arc { UI_READY_STATE.get_or_init(|| Arc::new(UiReadyState::default())) } @@ -94,6 +129,9 @@ pub fn mark_ui_ready() { let mut ready = get_ui_ready().write(); *ready = true; logging!(info, Type::Window, true, "UI已标记为完全就绪"); + + // If any deep links were queued while UI was not ready, handle them now + // No queued deep links list anymore; early and runtime deep links are deduped } // 重置UI就绪状态 @@ -110,6 +148,82 @@ pub fn reset_ui_ready() { logging!(info, Type::Window, true, "UI就绪状态已重置"); } +/// Schedule robust deep-link handling to avoid races with lightweight mode and window creation +pub fn schedule_handle_deep_link(url: String) { + AsyncHandler::spawn(move || async move { + // Normalize dedup key to the actual subscription URL inside the deep link + let dedup_key = (|| { + if let Ok(parsed) = Url::parse(&url) { + for (k, v) in parsed.query_pairs() { + if k == "url" { + return percent_decode_str(&v).decode_utf8_lossy().to_string(); + } + } + } + url.clone() + })(); + + // Deduplicate: if the same deep/subscription link was handled very recently, skip + { + let now = Instant::now(); + let mut last = LAST_DEEP_LINK.get_or_init(|| Mutex::new(None)).lock(); + if let Some((prev_url, prev_time)) = last.as_ref() { + if *prev_url == dedup_key && now.duration_since(*prev_time) < Duration::from_secs(5) { + log::warn!(target: "app", "Skip duplicate deep link within 5s: {}", dedup_key); + return; + } + } + *last = Some((dedup_key.clone(), now)); + } + // Wait until app handle exists + for i in 0..100u8 { + if Handle::global().app_handle().is_some() { + break; + } + if i % 10 == 0 { logging!(info, Type::Setup, true, "waiting for app handle... ({}ms)", i as u64 * 20); } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // Ensure we are not in lightweight mode (webview destroyed) + lightweight::exit_lightweight_mode(); + for _ in 0..150u16 { + if !lightweight::is_in_lightweight_mode() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // Ensure a window exists ASAP so UI can mount + #[cfg(target_os = "macos")] + { + AppHandleManager::global().set_activation_policy_regular(); + } + // If lightweight mode was active, give it a bit of time to unwind before recreating window + if lightweight::is_in_lightweight_mode() { + tokio::time::sleep(Duration::from_millis(200)).await; + } + let _ = WindowManager::show_main_window(); + + // Ensure profiles directory exists on cold start + if let Ok(dir) = crate::utils::dirs::app_profiles_dir() { + if !dir.exists() { + let _ = std::fs::create_dir_all(&dir); + } + } + + // Process deep link (add profile regardless of UI state) + logging!(info, Type::Setup, true, "processing deep link: {}", dedup_key); + if let Err(e) = resolve_scheme(url.clone()).await { + log::error!(target: "app", "Deep link handling failed: {e}"); + } + + // If UI is ready, small delay to let listeners settle before finishing + if is_ui_ready() { + tokio::time::sleep(Duration::from_millis(120)).await; + } + }); +} + pub async fn find_unused_port() -> Result { match TcpListener::bind("127.0.0.1:0").await { Ok(listener) => { @@ -286,21 +400,34 @@ pub fn create_window(is_show: bool) -> bool { if let Some(app_handle) = handle::Handle::global().app_handle() { if let Some(window) = app_handle.get_webview_window("main") { - logging!(info, Type::Window, true, "主窗口已存在,将显示现有窗口"); + logging!(info, Type::Window, true, "主窗口已存在,将尝试显示现有窗口"); if is_show { if window.is_minimized().unwrap_or(false) { logging!(info, Type::Window, true, "窗口已最小化,正在取消最小化"); let _ = window.unminimize(); } - let _ = window.show(); - let _ = window.set_focus(); + let show_result = window.show(); + let focus_result = window.set_focus(); - #[cfg(target_os = "macos")] - { - AppHandleManager::global().set_activation_policy_regular(); + // If showing or focusing fails (possibly destroyed webview after lightweight), fallback to recreate + if show_result.is_err() || focus_result.is_err() { + logging!( + warn, + Type::Window, + true, + "现有窗口显示失败,尝试销毁并重新创建" + ); + let _ = window.destroy(); + } else { + #[cfg(target_os = "macos")] + { + AppHandleManager::global().set_activation_policy_regular(); + } + return true; } + } else { + return true; } - return true; } } @@ -566,11 +693,12 @@ pub async fn resolve_scheme(param: String) -> Result<()> { Some(url) => { log::info!(target:"app", "decoded subscription url: {url}"); - create_window(true); + // Deep link inside resolver is now executed via schedule_handle_deep_link match PrfItem::from_url(url.as_ref(), name, None, None).await { Ok(item) => { let uid = item.uid.clone().unwrap(); let _ = wrap_err!(Config::profiles().data().append_item(item)); + // If UI not ready yet, message will be queued and flushed on ready handle::Handle::notice_message("import_sub_url::ok", uid); } Err(e) => { diff --git a/src/hooks/use-listen.ts b/src/hooks/use-listen.ts index 4d17b6b5..122b0b1e 100644 --- a/src/hooks/use-listen.ts +++ b/src/hooks/use-listen.ts @@ -19,7 +19,8 @@ export const useListen = () => { }; const setupCloseListener = async function () { - await event.once("tauri://close-requested", async () => { + // Do not clear listeners on close-requested (we hide to tray). Clean up only when window is destroyed. + await event.once("tauri://destroyed", async () => { removeAllListeners(); }); };