收紧桌面壳主窗口启动门禁

按 label=main 解析 Tauri 主窗口配置并补写宿主上下文

缺少主窗口配置时阻断启动而不是静默跳过 WebView 创建

补充桌面壳配置门禁和 Rust 单测覆盖

同步宿主壳方案文档与共享决策记录
This commit is contained in:
2026-06-19 05:41:13 +08:00
parent 93ded284c3
commit 41bccdd9fc
5 changed files with 120 additions and 35 deletions
+18 -1
View File
@@ -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}`);
+92 -34
View File
@@ -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<tauri::utils::config::WindowConfig> {
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<tauri::utils::config::WindowConfig> {
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));
}
}
@@ -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`
@@ -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 默认拒绝网页自动下载或 `<a download>` 触发的落盘动作;用户保存文本、图片、音频等内容必须走已声明的 `file.exportText``file.exportImage``file.exportAudio` HostBridge method,由 Rust 侧执行 MIME、大小、文件名清洗和系统保存对话框确认。
@@ -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。