桌面壳主动导航保留宿主上下文

抽出 Tauri H5 宿主上下文补写方法

让桌面深链和主动同源跳转共用宿主上下文

补充桌面壳检查脚本和文档决策
This commit is contained in:
2026-06-18 23:34:44 +08:00
parent dbcfa51191
commit 04d2498579
7 changed files with 157 additions and 53 deletions
@@ -1221,6 +1221,9 @@ const requiredRustHostSnippets = [
'file.imageDropped',
'app.notification().builder()',
'desktop_entry_url_with_platform',
'desktop_h5_url_with_host_context',
'desktop_h5_url_with_host_context(target_url)',
'desktop_h5_url_with_host_context(normalized_url)',
'desktop_window_config_with_runtime_platform',
'desktop_window_config_with_runtime_platform(config)',
'should_allow_desktop_webview_navigation',
@@ -1,20 +1,9 @@
use crate::host_bridge::capabilities::capabilities;
use crate::host_bridge::protocol::HOST_BRIDGE_VERSION;
use crate::shell::tray::show_main_window;
use crate::shell::webview::{desktop_platform, WEB_APP_ORIGIN};
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"];
const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [
"clientRuntime",
"clientType",
"hostShell",
"hostPlatform",
"hostVersion",
"bridgeVersion",
"hostCapabilities",
];
fn extract_path_from_custom_scheme(url: &Url) -> String {
let host = url.host_str().unwrap_or_default();
@@ -65,36 +54,12 @@ pub(crate) fn normalize_desktop_deep_link_url(raw_url: &Url) -> Option<Url> {
}
_ => return None,
};
let mut target_url = base_url.join(&target_path).ok()?;
let target_url = base_url.join(&target_path).ok()?;
if target_url.origin() != base_url.origin() {
return None;
}
let retained_query_pairs = target_url
.query_pairs()
.filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref()))
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<Vec<_>>();
target_url
.query_pairs_mut()
.clear()
.extend_pairs(
retained_query_pairs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.append_pair("clientRuntime", "native_app")
.append_pair("clientType", "native_app")
.append_pair("hostShell", "tauri_desktop")
.append_pair("hostPlatform", desktop_platform())
.append_pair("hostVersion", env!("CARGO_PKG_VERSION"))
.append_pair("bridgeVersion", &HOST_BRIDGE_VERSION.to_string())
.append_pair("hostCapabilities", &capabilities().join(","));
if target_url.origin() != base_url.origin() {
return None;
}
Some(target_url)
desktop_h5_url_with_host_context(target_url)
}
fn open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) {
@@ -1,5 +1,5 @@
use crate::shell::events::host_bridge_event_script;
use crate::shell::url::WEB_APP_ORIGIN;
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};
@@ -176,7 +176,7 @@ pub(crate) fn normalize_native_page_url(raw_url: &str) -> Option<Url> {
return None;
}
Some(normalized_url)
desktop_h5_url_with_host_context(normalized_url)
}
#[cfg(test)]
@@ -270,23 +270,46 @@ mod tests {
#[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!(
normalize_native_page_url("/works/detail?work=PZ-1")
.expect("same-origin route")
.as_str(),
"https://app.genarrative.world/works/detail?work=PZ-1"
route
.query_pairs()
.find(|(key, _)| key == "work")
.unwrap()
.1,
"PZ-1"
);
assert_eq!(
normalize_native_page_url("works/detail?work=PZ-1")
.expect("relative route")
.as_str(),
"https://app.genarrative.world/works/detail?work=PZ-1"
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://app.genarrative.world/works/detail?work=PZ-1")
.expect("absolute same-origin route")
.as_str(),
"https://app.genarrative.world/works/detail?work=PZ-1"
.query_pairs()
.any(|(key, value)| key == "hostShell" && value == "tauri_desktop")
);
}
@@ -1,8 +1,53 @@
use crate::host_bridge::capabilities::capabilities;
use crate::host_bridge::protocol::HOST_BRIDGE_VERSION;
use crate::shell::runtime::desktop_platform;
use std::path::PathBuf;
use tauri::{Url, WebviewUrl};
pub(crate) const WEB_APP_ORIGIN: &str = "https://app.genarrative.world";
const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [
"clientRuntime",
"clientType",
"hostShell",
"hostPlatform",
"hostVersion",
"bridgeVersion",
"hostCapabilities",
];
pub(crate) fn desktop_h5_url_with_host_context(mut target_url: Url) -> Option<Url> {
let base_url = Url::parse(WEB_APP_ORIGIN).ok()?;
if target_url.scheme() != "https" || target_url.origin() != base_url.origin() {
return None;
}
let retained_query_pairs = target_url
.query_pairs()
.filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref()))
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect::<Vec<_>>();
target_url
.query_pairs_mut()
.clear()
.extend_pairs(
retained_query_pairs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.append_pair("clientRuntime", "native_app")
.append_pair("clientType", "native_app")
.append_pair("hostShell", "tauri_desktop")
.append_pair("hostPlatform", desktop_platform())
.append_pair("hostVersion", env!("CARGO_PKG_VERSION"))
.append_pair("bridgeVersion", &HOST_BRIDGE_VERSION.to_string())
.append_pair("hostCapabilities", &capabilities().join(","));
if target_url.origin() != base_url.origin() {
return None;
}
Some(target_url)
}
pub(crate) fn desktop_entry_url_with_platform(raw_url: &str) -> String {
let platform = desktop_platform();
@@ -76,6 +121,65 @@ pub(crate) fn desktop_window_config_with_runtime_platform(
mod tests {
use super::*;
#[test]
fn desktop_h5_url_with_host_context_rewrites_runtime_query_once() {
let platform = desktop_platform();
let url = Url::parse(
"https://app.genarrative.world/creation/puzzle?work=PZ-1&clientRuntime=browser&hostCapabilities=old#draft",
)
.expect("desktop H5 url");
let url = desktop_h5_url_with_host_context(url).expect("context url");
assert_eq!(url.origin().ascii_serialization(), WEB_APP_ORIGIN);
assert_eq!(url.path(), "/creation/puzzle");
assert_eq!(url.fragment(), Some("draft"));
assert_eq!(
url.query_pairs()
.filter(|(key, _)| key == "clientRuntime")
.count(),
1
);
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "clientRuntime")
.unwrap()
.1,
"native_app"
);
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "hostPlatform")
.unwrap()
.1,
platform
);
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "hostVersion")
.unwrap()
.1,
env!("CARGO_PKG_VERSION")
);
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "hostCapabilities")
.unwrap()
.1,
capabilities().join(",")
);
assert_eq!(
url.query_pairs().find(|(key, _)| key == "work").unwrap().1,
"PZ-1"
);
}
#[test]
fn desktop_h5_url_with_host_context_rejects_non_h5_origin() {
let url = Url::parse("https://example.com/works/detail?work=PZ-1").expect("external url");
assert_eq!(desktop_h5_url_with_host_context(url), None);
}
#[test]
fn desktop_entry_url_replaces_static_platform_marker() {
let platform = desktop_platform();
@@ -12,4 +12,6 @@ pub(crate) use crate::shell::network::{
register_desktop_network_events, resolve_desktop_network_status,
};
pub(crate) use crate::shell::runtime::{color_scheme_from_theme, desktop_platform};
pub(crate) use crate::shell::url::{desktop_window_config_with_runtime_platform, WEB_APP_ORIGIN};
pub(crate) use crate::shell::url::{
desktop_h5_url_with_host_context, desktop_window_config_with_runtime_platform, WEB_APP_ORIGIN,
};
@@ -2508,6 +2508,13 @@
- 影响范围:`apps/mobile-shell/src/host-bridge/``apps/mobile-shell/src/shell/ShellApp.tsx`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run mobile-shell:test``npm run mobile-shell:typecheck``npm run check:native-shells``npm run typecheck -- --pretty false``npm run check:encoding``git diff --check`
## 2026-06-18 桌面壳主动导航保留宿主上下文
- 背景:Tauri 桌面壳 release / dev 入口和 deep link 都会给 H5 追加 `native_app``tauri_desktop`、当前平台、版本和 capability query;但 H5 通过 HostBridge 调用 `navigation.openNativePage` 主动跳转同源 route 时,如果只把裸同源 URL 交给主窗口,目标页可能按普通浏览器运行态启动。
- 决策:`navigation.openNativePage` 仍只接受 `https://app.genarrative.world` 同源 H5 route,不新增真实原生页面、不放宽外域导航;通过校验后的目标 URL 必须复用 `desktop_h5_url_with_host_context(...)`,与桌面 deep link 一样重写宿主上下文 query,确保主动导航后的页面继续带 `clientRuntime=native_app``hostShell=tauri_desktop`、当前平台、宿主版本和真实 capability 清单。
- 影响范围:`apps/desktop-shell/src-tauri/src/shell/url.rs``apps/desktop-shell/src-tauri/src/shell/navigation.rs``apps/desktop-shell/src-tauri/src/shell/deep_link.rs``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-18 桌面壳窗口状态持久化
- 背景:Tauri 桌面壳已经具备系统托盘、单实例、深链和受控 HostBridge 能力,但用户调整主窗口尺寸、位置或最大化状态后,重启桌面 App 仍回到固定初始窗口配置;如果直接保存完整窗口状态,又可能把托盘隐藏后的可见性状态带到下次启动。
File diff suppressed because one or more lines are too long