From 04d24985798823e3711643e66718cc05eb7cbdb8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 23:34:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=A3=B3=E4=B8=BB=E5=8A=A8?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E4=BF=9D=E7=95=99=E5=AE=BF=E4=B8=BB=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出 Tauri H5 宿主上下文补写方法 让桌面深链和主动同源跳转共用宿主上下文 补充桌面壳检查脚本和文档决策 --- apps/desktop-shell/scripts/check-config.mjs | 3 + .../src-tauri/src/shell/deep_link.rs | 41 +------ .../src-tauri/src/shell/navigation.rs | 47 ++++++-- apps/desktop-shell/src-tauri/src/shell/url.rs | 104 ++++++++++++++++++ .../src-tauri/src/shell/webview.rs | 4 +- .../shared-memory/decision-log.md | 7 ++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- 7 files changed, 157 insertions(+), 53 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 9cfdae5c0..83ce70d04 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -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', diff --git a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs index abe6b233f..6df8274a3 100644 --- a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs +++ b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs @@ -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 { } _ => 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::>(); - 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) { diff --git a/apps/desktop-shell/src-tauri/src/shell/navigation.rs b/apps/desktop-shell/src-tauri/src/shell/navigation.rs index 6f08de6d0..14e33934f 100644 --- a/apps/desktop-shell/src-tauri/src/shell/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/shell/navigation.rs @@ -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 { 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") ); } diff --git a/apps/desktop-shell/src-tauri/src/shell/url.rs b/apps/desktop-shell/src-tauri/src/shell/url.rs index 7e08b5975..81f66f02b 100644 --- a/apps/desktop-shell/src-tauri/src/shell/url.rs +++ b/apps/desktop-shell/src-tauri/src/shell/url.rs @@ -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 { + 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::>(); + 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(); diff --git a/apps/desktop-shell/src-tauri/src/shell/webview.rs b/apps/desktop-shell/src-tauri/src/shell/webview.rs index cbeaaf8ee..e9b33dde3 100644 --- a/apps/desktop-shell/src-tauri/src/shell/webview.rs +++ b/apps/desktop-shell/src-tauri/src/shell/webview.rs @@ -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, +}; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f659eac55..afcef2b58 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 仍回到固定初始窗口配置;如果直接保存完整窗口状态,又可能把托盘隐藏后的可见性状态带到下次启动。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 25828838d..652d3ec5f 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -188,7 +188,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 - 主 WebView 显式关闭 DevTools,Cargo 不启用 Tauri `devtools` feature;本地调试通过普通浏览器和 Vite 完成,不把可分发桌面壳变成调试容器。 - 崩溃上报、前端 analytics、桌面遥测日志、自动更新和渠道分发 SDK 都必须等真实端点、采集字段、用户同意、隐私策略、签名和发布流程确定后逐项接入;当前桌面壳不安装 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等相关依赖。 - 桌面壳和根 H5 包不安装 `@tauri-apps/api` 或 `@tauri-apps/plugin-*` JS guest 包;生产 H5 只通过 Tauri 注入的 `window.__TAURI__.core.invoke('host_bridge_request', request)` 进入 HostBridge。opener、clipboard、dialog、notification 等能力只保留 Rust Cargo 插件,由 Rust 内部分发并受 capability 白名单约束。 -- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://app.genarrative.world` URL;壳层会把目标路径归一为带 `native_app`、`tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。 +- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://app.genarrative.world` URL;壳层会把目标路径归一为带 `native_app`、`tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。`navigation.openNativePage` 的同源主动跳转也必须复用同一宿主上下文补写逻辑,避免新页面按普通浏览器运行态启动。 - 桌面窗口状态持久化属于宿主壳自有体验,不进入 HostBridge capability,也不开放窗口状态插件 command 给 H5。Tauri 壳只保存主窗口大小、位置和最大化状态,不保存可见性、全屏或装饰状态,避免托盘隐藏窗口后下次启动被恢复成隐藏状态。 桌面 release 和 dev 模式: @@ -344,7 +344,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端,H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `