8bd8363ffa
将原生壳公开主站统一为 www.genarrative.world 调整 Tauri release 直接加载线上主站并移除 frontendDist 打包 补齐创作入口配置开发代理与回归测试 同步原生壳方案文档和共享记忆
320 lines
9.9 KiB
Rust
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"));
|
|
}
|
|
}
|