Files
Genarrative/apps/desktop-shell/src-tauri/src/shell/deep_link.rs
T
kdletters 8bd8363ffa 修正原生壳公开主站配置
将原生壳公开主站统一为 www.genarrative.world

调整 Tauri release 直接加载线上主站并移除 frontendDist 打包

补齐创作入口配置开发代理与回归测试

同步原生壳方案文档和共享记忆
2026-06-22 15:07:02 +08:00

320 lines
9.9 KiB
Rust

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<Url> {
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<Option<Vec<Url>>, tauri_plugin_deep_link::Error>,
) -> Option<Vec<Url>> {
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"));
}
}