From 41bccdd9fcd1a1fb98595df006c15b8c657acf16 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 05:41:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E4=B8=BB=E7=AA=97=E5=8F=A3=E5=90=AF=E5=8A=A8=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 label=main 解析 Tauri 主窗口配置并补写宿主上下文 缺少主窗口配置时阻断启动而不是静默跳过 WebView 创建 补充桌面壳配置门禁和 Rust 单测覆盖 同步宿主壳方案文档与共享决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 19 ++- apps/desktop-shell/src-tauri/src/app.rs | 126 +++++++++++++----- .../shared-memory/decision-log.md | 7 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 1 + ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 + 5 files changed, 120 insertions(+), 35 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 350647b2b..c283f4f18 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1560,7 +1560,11 @@ const requiredRustHostSnippets = [ '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)', + 'DESKTOP_MAIN_WINDOW_LABEL: &str = "main"', + 'desktop_main_window_config_from_windows', + '.find(|window| window.label == DESKTOP_MAIN_WINDOW_LABEL)', + 'tauri::Error::WindowNotFound', + 'desktop_main_window_config(app)?', 'should_allow_desktop_webview_navigation', 'desktop_external_navigation_url', 'open_desktop_external_navigation', @@ -1754,6 +1758,7 @@ for (const entrypointOnlySnippet of [ for (const appSetupSnippet of [ 'tauri::Builder::default()', 'crate::host_bridge::host_bridge_request', + 'desktop_main_window_config(app)?', 'WebviewWindowBuilder::from_config', ]) { if (!app.includes(appSetupSnippet)) { @@ -1761,6 +1766,18 @@ for (const appSetupSnippet of [ } } +for (const blockedAppSetupSnippet of [ + 'app.config().app.windows.get(0)', + 'app.config().app.windows[0]', + 'if let Some(config) = window_config', +]) { + if (app.includes(blockedAppSetupSnippet)) { + throw new Error( + `desktop shell app.rs must resolve the labeled main window before startup, not ${blockedAppSetupSnippet}`, + ); + } +} + for (const snippet of requiredRustHostSnippets) { if (!rustHostSource.includes(snippet)) { throw new Error(`desktop shell Rust host bridge missing ${snippet}`); diff --git a/apps/desktop-shell/src-tauri/src/app.rs b/apps/desktop-shell/src-tauri/src/app.rs index 3344da995..6618d0f60 100644 --- a/apps/desktop-shell/src-tauri/src/app.rs +++ b/apps/desktop-shell/src-tauri/src/app.rs @@ -18,6 +18,25 @@ use crate::shell::webview::{ use crate::shell::window_state::desktop_window_state_plugin; use tauri::webview::NewWindowResponse; +const DESKTOP_MAIN_WINDOW_LABEL: &str = "main"; + +fn desktop_main_window_config_from_windows( + windows: &[tauri::utils::config::WindowConfig], +) -> tauri::Result { + windows + .iter() + .find(|window| window.label == DESKTOP_MAIN_WINDOW_LABEL) + .cloned() + .map(desktop_window_config_with_runtime_platform) + .ok_or(tauri::Error::WindowNotFound) +} + +fn desktop_main_window_config( + app: &tauri::App, +) -> tauri::Result { + desktop_main_window_config_from_windows(&app.config().app.windows) +} + pub(crate) fn run() { tauri::Builder::default() .manage(DesktopShareState::default()) @@ -44,40 +63,37 @@ pub(crate) fn run() { false } }; - 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 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 - }) - .on_page_load(|window, payload| { - if should_replay_desktop_webview_state_on_page_load(payload.event()) { - replay_desktop_webview_state(&window); - } - }) - .on_download(|_webview, event| should_allow_desktop_webview_download(&event)) - .build()?; - register_desktop_window_close_events(&window, tray_registered); - register_desktop_lifecycle_events(&window); - let _ = emit_current_desktop_lifecycle_event(&window); - let _ = register_desktop_network_events(&window); - let _ = register_desktop_navigation_events(&window); - register_desktop_file_drop_events(&window); - register_desktop_deep_link_events(app)?; - register_desktop_deep_link_schemes(app); - } + let config = desktop_main_window_config(app)?; + 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 + }) + .on_page_load(|window, payload| { + if should_replay_desktop_webview_state_on_page_load(payload.event()) { + replay_desktop_webview_state(&window); + } + }) + .on_download(|_webview, event| should_allow_desktop_webview_download(&event)) + .build()?; + register_desktop_window_close_events(&window, tray_registered); + register_desktop_lifecycle_events(&window); + let _ = emit_current_desktop_lifecycle_event(&window); + let _ = register_desktop_network_events(&window); + let _ = register_desktop_navigation_events(&window); + register_desktop_file_drop_events(&window); + register_desktop_deep_link_events(app)?; + register_desktop_deep_link_schemes(app); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -86,3 +102,45 @@ pub(crate) fn run() { .run(tauri::generate_context!()) .expect("failed to run Genarrative desktop shell"); } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn desktop_main_window_config_uses_labeled_main_window() { + let mut secondary_window = tauri::utils::config::WindowConfig::default(); + secondary_window.label = "secondary".to_string(); + secondary_window.url = tauri::WebviewUrl::App(PathBuf::from("secondary.html")); + + let mut main_window = tauri::utils::config::WindowConfig::default(); + main_window.label = "main".to_string(); + main_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html")); + + let config = desktop_main_window_config_from_windows(&[secondary_window, main_window]) + .expect("main window config"); + + assert_eq!(config.label, "main"); + match config.url { + tauri::WebviewUrl::App(path) => { + let path = path.to_string_lossy(); + assert!(path.starts_with("index.html?")); + assert!(path.contains("clientRuntime=native_app")); + assert!(path.contains("hostShell=tauri_desktop")); + } + other => panic!("unexpected main window url {other:?}"), + } + } + + #[test] + fn desktop_main_window_config_rejects_missing_main_window() { + let mut secondary_window = tauri::utils::config::WindowConfig::default(); + secondary_window.label = "secondary".to_string(); + + let error = desktop_main_window_config_from_windows(&[secondary_window]) + .expect_err("missing main window should fail startup"); + + assert!(matches!(error, tauri::Error::WindowNotFound)); + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b76980e0e..318e89cd2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2581,3 +2581,10 @@ - 决策:`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs` 在发送即时本地通知前先调用 `permission_state()`,已授权才发送;处于 prompt / prompt-with-rationale 时只在 Rust 侧调用 `request_permission()` 后复判;最终未授权返回 `host_error: notification permission denied`。桌面壳仍不把 `notification:*` 插件命令加入 capability permissions,不向 H5 暴露 notification 插件 JS guest API、远程推送、定时提醒或通知 token。 - 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.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 typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 桌面壳主窗口启动门禁 + +- 背景:Tauri 的手动窗口创建示例容易从 `app.config().app.windows[0]` 取配置;如果后续配置顺序变化或缺少 `label="main"`,桌面壳可能静默创建错误窗口,甚至在无主 WebView 的状态下完成启动,导致 HostBridge、生命周期、托盘、菜单和拖拽事件都挂不到真实主窗口。 +- 决策:`apps/desktop-shell/src-tauri/src/app.rs` 启动时必须通过 `desktop_main_window_config(app)?` 按 `label="main"` 解析主窗口配置,并在创建 `WebviewWindowBuilder` 前调用 `desktop_window_config_with_runtime_platform(...)` 补写宿主上下文。缺少 `main` 时返回 Tauri `WindowNotFound` 阻断启动;配置门禁拒绝按 `windows[0]` / `get(0)` 兜底或 `if let Some(config)` 静默跳过主窗口创建。 +- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs`、`apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run desktop-shell:test`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 3f4a2de8a..84323c999 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -195,6 +195,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 - Rust 侧只暴露一个受控 `host_bridge_request` command,再在 Rust 内部按 method 白名单分发。 - Tauri capabilities 只授予主窗口所需命令;默认不开放文件系统、shell、全局剪贴板或任意插件能力。 - 主窗口 capability 只授予 `allow-host-bridge-request`;不得使用 `core:default`、`core:*:default` 或插件 command 权限作为兜底。窗口、菜单、托盘、剪贴板、文件、通知和外链能力只能由 Rust 壳内部调用,再通过 `host_bridge_request` 的 HostBridge method 白名单分发给 H5。 +- 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗口配置,并在创建 WebView 前补写 `native_app`、`tauri_desktop` 和真实 capability 上下文;缺少主窗口配置时启动直接失败,不允许按 `windows[0]` 兜底或无主窗口静默运行。 - 桌面支付首期走现有 H5 / 二维码 / 外部浏览器路径,不在 Rust 侧保存支付凭据。 - 文件导出、作品卡保存、图片拖拽导入、应用菜单、系统托盘、自动更新等桌面能力按后续需求逐项开放;其中应用菜单和系统托盘属于桌面壳自有能力,不作为 HostBridge method 暴露给 H5。 - 主 WebView 默认拒绝网页自动下载或 `` 触发的落盘动作;用户保存文本、图片、音频等内容必须走已声明的 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method,由 Rust 侧执行 MIME、大小、文件名清洗和系统保存对话框确认。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 73879a272..ff6797fc8 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -41,6 +41,8 @@ AI H5 sandbox 当前 `npm run check:native-shells` 锁定的生产文件清单为:微信桥接层 `dispatch.js`、`payment.js`、`protocol.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;微信 shell 层 `payment.js`、`shareGrid.js`、`subscribeMessage.js`、`webView.js`;移动桥接层 `bridge.ts`、`capabilities.ts`、`dispatch.ts`、`files.ts`、`protocol.ts`、`scanner.ts`、`share.ts`;移动 shell 层 `QrScannerOverlay.tsx`、`ShellApp.tsx`、`deepLink.ts`、`lifecycle.ts`、`loadFailure.ts`、`navigation.ts`、`network.ts`、`runtime.ts`、`safeArea.ts`、`url.ts`、`webViewGlobals.d.ts`、`webViewHistory.ts`、`webViewPolicy.ts`;桌面入口 `app.rs`、`main.rs`;桌面桥接层 `capabilities.rs`、`dispatch.rs`、`files.rs`、`mod.rs`、`protocol.rs`、`share.rs`;桌面 shell 层 `deep_link.rs`、`events.rs`、`file_drop.rs`、`lifecycle.rs`、`menu.rs`、`mod.rs`、`navigation.rs`、`network.rs`、`runtime.rs`、`tray.rs`、`url.rs`、`webview.rs`、`window_state.rs`。 +Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗口配置,并在创建 WebView 前补写 `native_app`、`tauri_desktop` 和真实 capability 上下文;缺少主窗口配置时启动直接失败,不允许按 `windows[0]` 兜底或无主窗口静默运行。 + ## 首批能力 - `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。