收紧桌面壳主窗口启动门禁
按 label=main 解析 Tauri 主窗口配置并补写宿主上下文 缺少主窗口配置时阻断启动而不是静默跳过 WebView 创建 补充桌面壳配置门禁和 Rust 单测覆盖 同步宿主壳方案文档与共享决策记录
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user