From db4df0ab9a575a75b80e4030a1b39d4897460fde Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 10:39:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E9=A1=B6=E5=B1=82=E5=AF=BC=E8=88=AA=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面主窗口只允许打包资产和同源 H5 留壳 桌面外链与新窗口请求交给系统 opener 后拒绝留壳 同步宿主壳方案和共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 6 + apps/desktop-shell/src-tauri/src/main.rs | 105 +++++++++++++++++- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 57eb5db5d..6f1e02cf9 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -524,6 +524,12 @@ const requiredMainSnippets = [ 'desktop_entry_url_with_platform', 'desktop_window_config_with_runtime_platform', 'desktop_window_config_with_runtime_platform(config)', + 'should_allow_desktop_webview_navigation', + 'desktop_external_navigation_url', + 'open_desktop_external_navigation', + '.on_navigation(move |url|', + '.on_new_window(move |url, _features|', + 'NewWindowResponse::Deny', ]; assertSameList( diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index e082adf56..4398096f1 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -8,6 +8,7 @@ use std::sync::Mutex; use std::time::Duration; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::webview::NewWindowResponse; use tauri::DragDropEvent; use tauri::Manager; use tauri::Theme; @@ -283,6 +284,48 @@ fn normalize_external_url(raw_url: &str) -> Option { Some(url.to_string()) } +fn is_desktop_packaged_asset_url(url: &Url) -> bool { + if url.scheme() == "tauri" { + return true; + } + + url.scheme() == "https" + && url + .host_str() + .map(|host| host.ends_with(".localhost")) + .unwrap_or(false) +} + +fn should_allow_desktop_webview_navigation(url: &Url) -> bool { + if is_desktop_packaged_asset_url(url) { + 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 +} + +fn desktop_external_navigation_url(url: &Url) -> Option { + if should_allow_desktop_webview_navigation(url) { + return None; + } + + normalize_external_url(url.as_str()) +} + +fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) { + let Some(external_url) = desktop_external_navigation_url(url) else { + return; + }; + let _ = app.opener().open_url(external_url, None::<&str>); +} + fn normalize_native_page_url(raw_url: &str) -> Option { let url = raw_url.trim(); if url.is_empty() || url.chars().any(char::is_control) { @@ -1578,8 +1621,22 @@ fn main() { let window_config = app.config().app.windows.get(0).cloned(); if let Some(config) = window_config { let config = desktop_window_config_with_runtime_platform(config); - let window = - tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?; + let app_handle = app.handle().clone(); + let new_window_app_handle = app.handle().clone(); + let window = tauri::WebviewWindowBuilder::from_config(app.handle(), &config)? + .on_navigation(move |url| { + if should_allow_desktop_webview_navigation(url) { + true + } else { + open_desktop_external_navigation(&app_handle, url); + false + } + }) + .on_new_window(move |url, _features| { + open_desktop_external_navigation(&new_window_app_handle, &url); + NewWindowResponse::Deny + }) + .build()?; register_desktop_window_close_events(&window, tray_registered); register_desktop_lifecycle_events(&window); let _ = emit_desktop_lifecycle_event(&window, "active", true, "created"); @@ -1924,6 +1981,50 @@ mod tests { 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 same_origin_url = Url::parse("https://app.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); + } + + #[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 native_page_url_normalization_allows_same_origin_routes() { assert_eq!( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 922ab7b03..d820ce9e9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -57,6 +57,7 @@ - 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd / 深链内容作为事件透传给 H5。桌面深链后续如需接入,必须先定义受控 URL 归一和宿主边界,不能借单实例回调直接开放任意启动参数。 - 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载打包的 `index.html` 和根 `dist` H5 资产,dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。 - 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值;Rust `setup` 手动创建主窗口前必须把入口 URL 改写为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。 +- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。 - 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,防止把临时替身、占位文案或伪实现带进可分发壳。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 013ec5287..b7f0a960b 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -316,6 +316,8 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳静态 Tauri 配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值。Rust `setup` 手动创建主窗口前会把入口 URL 归一为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致;不通过第二实例参数、外部 deep link 或 H5 自报值覆盖该平台字段。 +2026-06-18 追加:桌面壳主 WebView 增加顶层导航边界。打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 可以继续留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航和 `window.open` 请求只交给系统 opener 后阻止留壳;`javascript:`、`file:` 等危险协议直接阻断。该规则不新增 HostBridge capability,也不开放 opener 插件 JS guest API,避免外域页面停留在带 `host_bridge_request` 权限的主 WebView 内。 + 2026-06-18 追加:桌面壳命令暴露面收紧为唯一受控入口。Tauri `build.rs` manifest、Rust `generate_handler!`、主窗口 capability 权限列表和本地自动生成权限目录都只能出现 `host_bridge_request`;如果本地构建残留了其它 command 的自动生成权限文件,`apps/desktop-shell/scripts/check-config.mjs` 会直接失败。后续接入新的桌面系统能力时仍先扩展 HostBridge method 和 Rust 内部分发,不新增可被 H5 直接 invoke 的 Tauri command,也不把插件 JS guest API 授权给主窗口。 2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*` 或 `ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp`。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。