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