diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 6f1e02cf9..5013ce6e3 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -529,6 +529,10 @@ const requiredMainSnippets = [ 'open_desktop_external_navigation', '.on_navigation(move |url|', '.on_new_window(move |url, _features|', + 'DownloadEvent', + 'should_allow_desktop_webview_download', + 'DownloadEvent::Requested { .. } => false', + '.on_download(|_webview, event| should_allow_desktop_webview_download(&event))', 'NewWindowResponse::Deny', ]; diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 4398096f1..bb5d66b7a 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -8,7 +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::webview::{DownloadEvent, NewWindowResponse}; use tauri::DragDropEvent; use tauri::Manager; use tauri::Theme; @@ -326,6 +326,14 @@ fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) { let _ = app.opener().open_url(external_url, None::<&str>); } +fn should_allow_desktop_webview_download(event: &DownloadEvent<'_>) -> bool { + match event { + DownloadEvent::Requested { .. } => false, + DownloadEvent::Finished { .. } => true, + _ => false, + } +} + fn normalize_native_page_url(raw_url: &str) -> Option { let url = raw_url.trim(); if url.is_empty() || url.chars().any(char::is_control) { @@ -1636,6 +1644,7 @@ fn main() { open_desktop_external_navigation(&new_window_app_handle, &url); NewWindowResponse::Deny }) + .on_download(|_webview, event| should_allow_desktop_webview_download(&event)) .build()?; register_desktop_window_close_events(&window, tray_registered); register_desktop_lifecycle_events(&window); @@ -2025,6 +2034,27 @@ mod tests { 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://app.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 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 d820ce9e9..bf084129f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -58,6 +58,7 @@ - 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 主 WebView 的下载事件默认拒绝网页自动下载和 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 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 b7f0a960b..682a1d337 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -179,6 +179,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 - Tauri capabilities 只授予主窗口所需命令;默认不开放文件系统、shell、全局剪贴板或任意插件能力。 - 桌面支付首期走现有 H5 / 二维码 / 外部浏览器路径,不在 Rust 侧保存支付凭据。 - 文件导出、作品卡保存、图片拖拽导入、系统托盘、自动更新等桌面能力按后续需求逐项开放;其中系统托盘属于桌面壳自有能力,不作为 HostBridge method 暴露给 H5。 +- 主 WebView 默认拒绝网页自动下载或 `` 触发的落盘动作;用户保存文本、图片、音频等内容必须走已声明的 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method,由 Rust 侧执行 MIME、大小、文件名清洗和系统保存对话框确认。 桌面 release 和 dev 模式: @@ -233,6 +234,7 @@ GameBridge 禁止: - 登录和支付能力在真实 SDK、渠道流程、后端契约和失败回退全部落地前不得进入 Expo / Tauri capabilities,也不得写进入口 URL `hostCapabilities`;两端配置检查会拒绝 `auth.requestLogin` 和 `payment.request` 的伪声明。 - 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。 - Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。 +- Tauri 主 WebView 禁止默认下载落盘;桌面文件保存只能通过受控 HostBridge 导出能力进入系统保存对话框。 - RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。 - AI sandbox iframe 必须使用独立 CSP、`sandbox` 属性和单独 GameBridge allowlist。