use crate::shell::lifecycle::log_desktop_host_event_result; use crate::shell::tray::show_main_window; use crate::shell::webview::{desktop_h5_url_with_host_context, WEB_APP_ORIGIN}; use tauri::{Manager, Url, WebviewWindow}; use tauri_plugin_deep_link::DeepLinkExt; const DESKTOP_DEEP_LINK_HOSTS: [&str; 2] = ["open", "app"]; fn extract_path_from_custom_scheme(url: &Url) -> String { let host = url.host_str().unwrap_or_default(); if DESKTOP_DEEP_LINK_HOSTS.contains(&host) { return format!( "{}{}{}", url.path(), url.query() .map(|query| format!("?{query}")) .unwrap_or_default(), url.fragment() .map(|fragment| format!("#{fragment}")) .unwrap_or_default(), ); } format!( "{}{}{}{}", if host.is_empty() { "" } else { "/" }, host, url.path(), url.query() .map(|query| format!("?{query}")) .unwrap_or_default(), ) + &url .fragment() .map(|fragment| format!("#{fragment}")) .unwrap_or_default() } pub(crate) fn normalize_desktop_deep_link_url(raw_url: &Url) -> Option { let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; let target_path = match raw_url.scheme() { "genarrative" => extract_path_from_custom_scheme(raw_url), "https" if raw_url.origin() == base_url.origin() => { format!( "{}{}{}", raw_url.path(), raw_url .query() .map(|query| format!("?{query}")) .unwrap_or_default(), raw_url .fragment() .map(|fragment| format!("#{fragment}")) .unwrap_or_default(), ) } _ => return None, }; let target_url = base_url.join(&target_path).ok()?; if target_url.origin() != base_url.origin() { return None; } desktop_h5_url_with_host_context(target_url) } fn open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) -> tauri::Result<()> { let Some(target_url) = normalize_desktop_deep_link_url(url) else { return Ok(()); }; window.navigate(target_url)?; show_main_window(window.app_handle()) } fn log_desktop_deep_link_open_result(result: tauri::Result<()>) -> bool { log_desktop_host_event_result("deep_link.open", result) } fn log_desktop_deep_link_register_result( result: Result<(), tauri_plugin_deep_link::Error>, ) -> bool { match result { Ok(()) => true, Err(_error) => { eprintln!("desktop host event failed for deep_link.register"); false } } } fn log_desktop_deep_link_current_result( result: Result>, tauri_plugin_deep_link::Error>, ) -> Option> { match result { Ok(urls) => urls, Err(_error) => { eprintln!("desktop host event failed for deep_link.current"); None } } } fn log_desktop_deep_link_window_missing(stage: &str) -> bool { eprintln!("desktop host event failed for deep_link.window: main window unavailable during {stage}"); false } pub(crate) fn register_desktop_deep_link_events(app: &tauri::App) -> tauri::Result<()> { let app_handle = app.handle().clone(); app.deep_link().on_open_url(move |event| { let Some(window) = app_handle.get_webview_window("main") else { log_desktop_deep_link_window_missing("open"); return; }; for url in event.urls() { log_desktop_deep_link_open_result(open_desktop_deep_link_url(&window, &url)); } }); let Some(window) = app.get_webview_window("main") else { log_desktop_deep_link_window_missing("current"); return Ok(()); }; if let Some(urls) = log_desktop_deep_link_current_result(app.deep_link().get_current()) { for url in urls { log_desktop_deep_link_open_result(open_desktop_deep_link_url(&window, &url)); } } Ok(()) } pub(crate) fn register_desktop_deep_link_schemes(app: &tauri::App) -> bool { log_desktop_deep_link_register_result(app.deep_link().register_all()) } #[cfg(test)] mod tests { use super::*; fn normalized(raw_url: &str) -> Url { normalize_desktop_deep_link_url(&Url::parse(raw_url).expect("deep link url")) .expect("normalized deep link url") } #[test] fn custom_scheme_open_host_maps_to_same_origin_h5_route_with_host_context() { let url = normalized("genarrative://open/works/detail?work=PZ-1#play"); assert_eq!(url.origin().ascii_serialization(), WEB_APP_ORIGIN); assert_eq!(url.path(), "/works/detail"); assert_eq!( url.query_pairs().find(|(key, _)| key == "work").unwrap().1, "PZ-1" ); assert_eq!( url.query_pairs() .find(|(key, _)| key == "clientRuntime") .unwrap() .1, "native_app" ); assert_eq!( url.query_pairs() .find(|(key, _)| key == "hostShell") .unwrap() .1, "tauri_desktop" ); assert_eq!(url.fragment(), Some("play")); } #[test] fn custom_scheme_without_reserved_host_keeps_host_as_first_path_segment() { let url = normalized("genarrative://works/detail?work=PZ-1"); assert_eq!(url.path(), "/works/detail"); assert_eq!( url.query_pairs().find(|(key, _)| key == "work").unwrap().1, "PZ-1" ); } #[test] fn same_origin_https_link_keeps_path_and_restores_desktop_host_context() { let url = normalized("https://www.genarrative.world/creation/puzzle?clientRuntime=browser#draft"); assert_eq!(url.path(), "/creation/puzzle"); assert_eq!( url.query_pairs() .filter(|(key, _)| key == "clientRuntime") .count(), 1 ); assert_eq!( url.query_pairs() .find(|(key, _)| key == "clientType") .unwrap() .1, "native_app" ); assert_eq!(url.fragment(), Some("draft")); } #[test] fn external_and_unsafe_links_are_rejected() { for raw_url in [ "https://example.com/works/detail?work=PZ-1", "http://www.genarrative.world/works/detail?work=PZ-1", "mailto:hi@example.com", "javascript:alert(1)", ] { let url = Url::parse(raw_url).expect("url"); assert_eq!(normalize_desktop_deep_link_url(&url), None); } } #[test] fn desktop_deep_link_config_check_requires_custom_scheme() { let configured = serde_json::json!({ "plugins": { "deep-link": { "desktop": { "schemes": ["genarrative"] } } } }); let missing = serde_json::json!({ "plugins": { "deep-link": { "desktop": { "schemes": ["example"] } } } }); assert_eq!( configured .get("plugins") .and_then(|plugins| plugins.get("deep-link")) .and_then(|deep_link| deep_link.get("desktop")) .and_then(|desktop| desktop.get("schemes")) .and_then(serde_json::Value::as_array) .and_then(|schemes| schemes.first()) .and_then(serde_json::Value::as_str), Some("genarrative") ); assert_ne!( missing .get("plugins") .and_then(|plugins| plugins.get("deep-link")) .and_then(|deep_link| deep_link.get("desktop")) .and_then(|desktop| desktop.get("schemes")) .and_then(serde_json::Value::as_array) .and_then(|schemes| schemes.first()) .and_then(serde_json::Value::as_str), Some("genarrative") ); } #[test] fn desktop_deep_link_open_result_reports_success_and_failure() { assert!(log_desktop_deep_link_open_result(Ok(()))); assert!(!log_desktop_deep_link_open_result(Err( tauri::Error::AssetNotFound("deep-link".to_string()) ))); } #[test] fn desktop_deep_link_register_result_reports_success_and_failure() { assert!(log_desktop_deep_link_register_result(Ok(()))); assert!(!log_desktop_deep_link_register_result(Err( tauri_plugin_deep_link::Error::UnsupportedPlatform ))); } #[test] fn desktop_deep_link_register_result_logs_stable_label_only() { assert!(!log_desktop_deep_link_register_result(Err( tauri_plugin_deep_link::Error::UnsupportedPlatform ))); } #[test] fn desktop_deep_link_current_result_reports_success_empty_and_failure() { let urls = vec![Url::parse("genarrative://open/works/detail?work=PZ-1").expect("url")]; assert_eq!( log_desktop_deep_link_current_result(Ok(Some(urls.clone()))), Some(urls) ); assert_eq!(log_desktop_deep_link_current_result(Ok(None)), None); assert_eq!( log_desktop_deep_link_current_result(Err( tauri_plugin_deep_link::Error::UnsupportedPlatform )), None ); } #[test] fn desktop_deep_link_current_result_logs_stable_label_only() { assert_eq!( log_desktop_deep_link_current_result(Err( tauri_plugin_deep_link::Error::UnsupportedPlatform )), None ); } #[test] fn desktop_deep_link_window_missing_reports_failure() { assert!(!log_desktop_deep_link_window_missing("open")); assert!(!log_desktop_deep_link_window_missing("current")); } }