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

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

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

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

437 lines
16 KiB
Rust

use crate::shell::events::host_bridge_event_script;
use crate::shell::lifecycle::log_desktop_host_event_result;
use crate::shell::url::{desktop_h5_url_with_host_context, WEB_APP_ORIGIN};
use serde_json::{json, Value};
use tauri::webview::DownloadEvent;
use tauri::{Url, WebviewWindow};
use tauri_plugin_opener::OpenerExt;
const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"];
pub(crate) fn normalize_external_url(raw_url: &str) -> Option<String> {
let url = raw_url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return None;
}
let parsed_url = Url::parse(url).ok()?;
let protocol_with_colon = format!("{}:", parsed_url.scheme().to_ascii_lowercase());
if !EXTERNAL_URL_PROTOCOLS.contains(&protocol_with_colon.as_str()) {
return None;
}
Some(parsed_url.to_string())
}
pub(crate) fn desktop_navigation_can_go_back_payload(can_go_back: bool) -> Value {
json!({
"canGoBack": can_go_back,
})
}
pub(crate) fn desktop_navigation_state_script() -> Result<String, serde_json::Error> {
let can_go_back_script = host_bridge_event_script(
"navigation.canGoBack",
desktop_navigation_can_go_back_payload(true),
)?;
let cannot_go_back_script = host_bridge_event_script(
"navigation.canGoBack",
desktop_navigation_can_go_back_payload(false),
)?;
// 中文注释:桌面壳只追踪当前 H5 文档内由应用写入的 history index,不读取平台私有 back stack。
Ok(format!(
"(() => {{
if (window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__) {{
if (typeof window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__ === 'function') {{
window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__();
}}
return true;
}}
const historyIndexKey = '__genarrativeDesktopHistoryIndex';
const readIndex = (state) => {{
if (!state || typeof state !== 'object') {{
return null;
}}
const value = state[historyIndexKey];
return Number.isInteger(value) && value >= 0 ? value : null;
}};
const stateWithIndex = (state, index) => {{
if (state && typeof state === 'object' && !Array.isArray(state)) {{
return {{ ...state, [historyIndexKey]: index }};
}}
return {{ [historyIndexKey]: index }};
}};
const originalPushState = window.history.pushState.bind(window.history);
const originalReplaceState = window.history.replaceState.bind(window.history);
let currentIndex = readIndex(window.history.state) ?? 0;
const emitCanGoBack = () => {{ {} }};
const emitCannotGoBack = () => {{ {} }};
const emitNavigationState = () => {{
if (currentIndex > 0) {{
emitCanGoBack();
}} else {{
emitCannotGoBack();
}}
}};
const replaceCurrentState = () => {{
try {{
originalReplaceState(stateWithIndex(window.history.state, currentIndex), '', window.location.href);
}} catch (error) {{
console.warn('desktop navigation state sync failed');
}}
}};
window.__GENARRATIVE_DESKTOP_EMIT_NAVIGATION_STATE__ = emitNavigationState;
window.history.pushState = (state, title, url) => {{
const nextIndex = currentIndex + 1;
const result = originalPushState(stateWithIndex(state, nextIndex), title, url);
currentIndex = nextIndex;
emitNavigationState();
return result;
}};
window.history.replaceState = (state, title, url) => {{
const result = originalReplaceState(stateWithIndex(state, currentIndex), title, url);
emitNavigationState();
return result;
}};
window.addEventListener('popstate', (event) => {{
const nextIndex = readIndex(event.state);
currentIndex = nextIndex ?? 0;
if (nextIndex === null) {{
replaceCurrentState();
}}
emitNavigationState();
}});
window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__ = true;
replaceCurrentState();
emitNavigationState();
return true;
}})();",
can_go_back_script, cannot_go_back_script
))
}
pub(crate) fn register_desktop_navigation_events(window: &WebviewWindow) -> tauri::Result<()> {
window.eval(desktop_navigation_state_script().map_err(tauri::Error::Json)?)
}
fn is_desktop_packaged_asset_url(url: &Url) -> bool {
if url.scheme() == "tauri" {
return true;
}
matches!(url.scheme(), "http" | "https")
&& url
.host_str()
.map(|host| host.ends_with(".localhost"))
.unwrap_or(false)
}
pub(crate) fn should_allow_desktop_webview_navigation(url: &Url) -> bool {
if is_desktop_packaged_asset_url(url) {
return true;
}
#[cfg(dev)]
if url.scheme() == "http"
&& url.host_str() == Some("127.0.0.1")
&& url.port_or_known_default() == Some(3000)
{
return true;
}
if url.scheme() == "https" {
let base_url = Url::parse(WEB_APP_ORIGIN).ok();
return base_url
.map(|base_url| url.origin() == base_url.origin())
.unwrap_or(false);
}
false
}
pub(crate) fn desktop_external_navigation_url(url: &Url) -> Option<String> {
if should_allow_desktop_webview_navigation(url) {
return None;
}
normalize_external_url(url.as_str())
}
pub(crate) fn open_normalized_desktop_external_url(
app: &tauri::AppHandle,
external_url: String,
) -> tauri::Result<()> {
app.opener()
.open_url(external_url, None::<&str>)
.map_err(|error| tauri::Error::Anyhow(error.into()))
}
pub(crate) fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) {
let Some(external_url) = desktop_external_navigation_url(url) else {
return;
};
log_desktop_host_event_result(
"navigation.external",
open_normalized_desktop_external_url(app, external_url),
);
}
pub(crate) fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool {
match event {
DownloadEvent::Requested { .. } => false,
DownloadEvent::Finished { .. } => true,
_ => false,
}
}
fn log_desktop_webview_download_blocked(_url: &Url) -> bool {
eprintln!("desktop host event blocked for webview.download");
false
}
pub(crate) fn handle_desktop_webview_download(event: &DownloadEvent<'_>) -> bool {
if let DownloadEvent::Requested { url, .. } = event {
log_desktop_webview_download_blocked(url);
}
should_allow_desktop_webview_download(event)
}
pub(crate) fn normalize_native_page_url(raw_url: &str) -> Option<Url> {
let url = raw_url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return None;
}
let base_url = Url::parse(WEB_APP_ORIGIN).ok()?;
let normalized_url = base_url.join(url).ok()?;
if normalized_url.scheme() != "https" || normalized_url.origin() != base_url.origin() {
return None;
}
desktop_h5_url_with_host_context(normalized_url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn external_url_normalization_allows_only_safe_protocols() {
assert_eq!(
normalize_external_url(" https://example.com/path "),
Some("https://example.com/path".to_string())
);
assert_eq!(
normalize_external_url("mailto:hi@example.com"),
Some("mailto:hi@example.com".to_string())
);
assert_eq!(
normalize_external_url("tel:+12345678"),
Some("tel:+12345678".to_string())
);
assert_eq!(normalize_external_url("javascript:alert(1)"), None);
assert_eq!(normalize_external_url("file:///etc/passwd"), None);
assert_eq!(normalize_external_url("http://exa mple.com/path"), None);
assert_eq!(normalize_external_url("https://example.com/\nnext"), None);
assert_eq!(normalize_external_url("/relative/path"), None);
}
#[test]
fn desktop_webview_navigation_stays_on_packaged_or_same_origin_pages() {
let packaged_url = Url::parse("tauri://localhost/index.html").expect("packaged url");
assert!(should_allow_desktop_webview_navigation(&packaged_url));
assert_eq!(desktop_external_navigation_url(&packaged_url), None);
let windows_packaged_url =
Url::parse("https://tauri.localhost/index.html").expect("windows packaged url");
assert!(should_allow_desktop_webview_navigation(
&windows_packaged_url
));
assert_eq!(desktop_external_navigation_url(&windows_packaged_url), None);
let windows_http_packaged_url =
Url::parse("http://tauri.localhost/index.html").expect("windows http packaged url");
assert!(should_allow_desktop_webview_navigation(
&windows_http_packaged_url
));
assert_eq!(
desktop_external_navigation_url(&windows_http_packaged_url),
None
);
let same_origin_url = Url::parse("https://www.genarrative.world/works/detail?work=PZ-1")
.expect("same-origin url");
assert!(should_allow_desktop_webview_navigation(&same_origin_url));
assert_eq!(desktop_external_navigation_url(&same_origin_url), None);
#[cfg(dev)]
{
let dev_url =
Url::parse("http://127.0.0.1:3000/works/detail?work=PZ-1").expect("dev url");
assert!(should_allow_desktop_webview_navigation(&dev_url));
assert_eq!(desktop_external_navigation_url(&dev_url), None);
}
}
#[test]
fn desktop_webview_navigation_sends_external_urls_to_system_only() {
let external_url = Url::parse("https://example.com/path").expect("external url");
assert!(!should_allow_desktop_webview_navigation(&external_url));
assert_eq!(
desktop_external_navigation_url(&external_url),
Some("https://example.com/path".to_string())
);
let mail_url = Url::parse("mailto:hi@example.com").expect("mail url");
assert!(!should_allow_desktop_webview_navigation(&mail_url));
assert_eq!(
desktop_external_navigation_url(&mail_url),
Some("mailto:hi@example.com".to_string())
);
let unsafe_url = Url::parse("javascript:alert(1)").expect("unsafe url");
assert!(!should_allow_desktop_webview_navigation(&unsafe_url));
assert_eq!(desktop_external_navigation_url(&unsafe_url), None);
let file_url = Url::parse("file:///etc/passwd").expect("file url");
assert!(!should_allow_desktop_webview_navigation(&file_url));
assert_eq!(desktop_external_navigation_url(&file_url), None);
}
#[test]
fn desktop_webview_downloads_are_blocked_by_default() {
let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt");
let url = Url::parse("https://www.genarrative.world/download.txt").expect("download url");
let requested = DownloadEvent::Requested {
url: url.clone(),
destination: &mut destination,
};
assert!(!should_allow_desktop_webview_download(&requested));
let finished = DownloadEvent::Finished {
url,
path: None,
success: false,
};
assert!(should_allow_desktop_webview_download(&finished));
}
#[test]
fn desktop_webview_download_handler_logs_blocked_requests() {
let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt");
let url = Url::parse("https://www.genarrative.world/download.txt").expect("download url");
let requested = DownloadEvent::Requested {
url: url.clone(),
destination: &mut destination,
};
assert!(!log_desktop_webview_download_blocked(&url));
assert!(!handle_desktop_webview_download(&requested));
}
#[test]
fn desktop_webview_download_blocked_logs_stable_label_only() {
let url = Url::parse(
"https://www.genarrative.world/download.txt?token=private&work=PZ-1",
)
.expect("download url");
assert!(!log_desktop_webview_download_blocked(&url));
}
#[test]
fn native_page_url_normalization_allows_same_origin_routes() {
let route =
normalize_native_page_url("/works/detail?work=PZ-1").expect("same-origin route");
assert_eq!(route.origin().ascii_serialization(), WEB_APP_ORIGIN);
assert_eq!(route.path(), "/works/detail");
assert_eq!(
route
.query_pairs()
.find(|(key, _)| key == "work")
.unwrap()
.1,
"PZ-1"
);
assert_eq!(
route
.query_pairs()
.find(|(key, _)| key == "clientRuntime")
.unwrap()
.1,
"native_app"
);
assert_eq!(
route
.query_pairs()
.find(|(key, _)| key == "hostShell")
.unwrap()
.1,
"tauri_desktop"
);
assert!(route
.query_pairs()
.any(|(key, value)| key == "hostCapabilities" && value.contains("host.getRuntime")));
assert!(normalize_native_page_url("works/detail?work=PZ-1")
.expect("relative route")
.query_pairs()
.any(|(key, value)| key == "clientType" && value == "native_app"));
assert!(
normalize_native_page_url("https://www.genarrative.world/works/detail?work=PZ-1")
.expect("absolute same-origin route")
.query_pairs()
.any(|(key, value)| key == "hostShell" && value == "tauri_desktop")
);
}
#[test]
fn native_page_url_normalization_rejects_unsafe_routes() {
assert_eq!(normalize_native_page_url("https://example.com/works"), None);
assert_eq!(normalize_native_page_url("//example.com/works"), None);
assert_eq!(normalize_native_page_url("javascript:alert(1)"), None);
assert_eq!(
normalize_native_page_url("https://www.genarrative.world/\nnext"),
None
);
}
#[test]
fn desktop_navigation_can_go_back_payload_reports_boolean_state() {
assert_eq!(
desktop_navigation_can_go_back_payload(true),
json!({
"canGoBack": true,
})
);
assert_eq!(
desktop_navigation_can_go_back_payload(false),
json!({
"canGoBack": false,
})
);
}
#[test]
fn desktop_navigation_state_script_tracks_h5_history_changes() {
let script = desktop_navigation_state_script().expect("navigation state script");
assert!(script.contains("navigation.canGoBack"));
assert!(script.contains("window.history.pushState"));
assert!(script.contains("window.history.replaceState"));
assert!(script.contains("window.addEventListener('popstate'"));
assert!(script.contains("__genarrativeDesktopHistoryIndex"));
assert!(script.contains("(currentIndex > 0)"));
assert!(script.contains("\\\"canGoBack\\\":true"));
assert!(script.contains("\\\"canGoBack\\\":false"));
assert!(script.contains("console.warn('desktop navigation state sync failed')"));
assert!(!script.contains("console.warn('desktop navigation state sync failed', error)"));
assert!(script.contains("window.__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__"));
}
}