收紧桌面壳事件注册失败处理

桌面壳新窗口策略增加可测试决策函数

桌面壳 WebView 事件注册失败改为启动阻断或日志记录

桌面壳配置门禁禁止事件注册静默吞错

宿主壳协议和共享决策记录补充事件注册失败规则
This commit is contained in:
2026-06-20 05:27:02 +08:00
parent 6c0d929e91
commit 73ea93ccc8
6 changed files with 122 additions and 14 deletions
@@ -2213,6 +2213,7 @@ const requiredRustHostSnippets = [
'should_replay_desktop_webview_state_on_page_load',
'PageLoadEvent::Finished',
'replay_desktop_webview_state',
'log_desktop_webview_state_replay_result',
'window.is_focused()',
'window.is_visible()',
'window.is_minimized()',
@@ -2242,6 +2243,8 @@ const requiredRustHostSnippets = [
'desktop_external_navigation_url',
'open_normalized_desktop_external_url',
'open_desktop_external_navigation',
'desktop_new_window_external_url',
'desktop_new_window_response',
'.on_navigation(move |url|',
'.on_new_window(move |url, _features|',
'.on_page_load(|window, payload|',
@@ -2673,6 +2676,11 @@ for (const appSetupSnippet of [
'crate::host_bridge::host_bridge_request',
'desktop_main_window_config(app)?',
'WebviewWindowBuilder::from_config',
'desktop_new_window_external_url(&url)',
'desktop_new_window_response',
'register_desktop_network_events(&window)?',
'register_desktop_navigation_events(&window)?',
'log_desktop_webview_state_replay_result(',
]) {
if (!app.includes(appSetupSnippet)) {
throw new Error(`desktop shell app.rs missing setup snippet ${appSetupSnippet}`);
@@ -2683,6 +2691,9 @@ for (const blockedAppSetupSnippet of [
'app.config().app.windows.get(0)',
'app.config().app.windows[0]',
'if let Some(config) = window_config',
'let _ = register_desktop_network_events(&window)',
'let _ = register_desktop_navigation_events(&window)',
'let _ = emit_current_desktop_lifecycle_event(&window)',
]) {
if (app.includes(blockedAppSetupSnippet)) {
throw new Error(
@@ -2691,6 +2702,18 @@ for (const blockedAppSetupSnippet of [
}
}
for (const blockedLifecycleSnippet of [
'let _ = register_desktop_network_events(window)',
'let _ = register_desktop_navigation_events(window)',
'let _ = emit_current_desktop_lifecycle_event(window)',
]) {
if (rustHostSource.includes(blockedLifecycleSnippet)) {
throw new Error(
`desktop shell WebView state replay failures must be logged or propagated, not ${blockedLifecycleSnippet}`,
);
}
}
for (const snippet of requiredRustHostSnippets) {
if (!rustHostSource.includes(snippet)) {
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
+50 -6
View File
@@ -9,7 +9,9 @@ use crate::shell::tray::{
};
use crate::shell::webview::{
desktop_window_config_with_runtime_platform, emit_current_desktop_lifecycle_event,
open_desktop_external_navigation, register_desktop_file_drop_events,
desktop_external_navigation_url,
log_desktop_webview_state_replay_result, open_desktop_external_navigation,
open_normalized_desktop_external_url, register_desktop_file_drop_events,
register_desktop_lifecycle_events, register_desktop_navigation_events,
register_desktop_network_events, replay_desktop_webview_state,
should_allow_desktop_webview_download, should_allow_desktop_webview_navigation,
@@ -37,6 +39,14 @@ fn desktop_main_window_config(
desktop_main_window_config_from_windows(&app.config().app.windows)
}
fn desktop_new_window_external_url(url: &tauri::Url) -> Option<String> {
desktop_external_navigation_url(url)
}
fn desktop_new_window_response<R: tauri::Runtime>() -> NewWindowResponse<R> {
NewWindowResponse::Deny
}
pub(crate) fn run() {
tauri::Builder::default()
.manage(DesktopShareState::default())
@@ -76,8 +86,13 @@ pub(crate) fn run() {
}
})
.on_new_window(move |url, _features| {
open_desktop_external_navigation(&new_window_app_handle, &url);
NewWindowResponse::Deny
if let Some(external_url) = desktop_new_window_external_url(&url) {
let _ = open_normalized_desktop_external_url(
&new_window_app_handle,
external_url,
);
}
desktop_new_window_response::<tauri::Wry>()
})
.on_page_load(|window, payload| {
if should_replay_desktop_webview_state_on_page_load(payload.event()) {
@@ -88,9 +103,12 @@ pub(crate) fn run() {
.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);
log_desktop_webview_state_replay_result(
"app.lifecycle",
emit_current_desktop_lifecycle_event(&window),
);
register_desktop_network_events(&window)?;
register_desktop_navigation_events(&window)?;
register_desktop_file_drop_events(&window);
register_desktop_deep_link_events(app)?;
register_desktop_deep_link_schemes(app);
@@ -143,4 +161,30 @@ mod tests {
assert!(matches!(error, tauri::Error::WindowNotFound));
}
#[test]
fn desktop_new_window_policy_denies_embedded_windows() {
assert!(matches!(
desktop_new_window_response::<tauri::Wry>(),
NewWindowResponse::Deny
));
}
#[test]
fn desktop_new_window_policy_opens_only_safe_external_urls() {
let external_url = tauri::Url::parse("https://example.com/share")
.expect("external desktop new window url");
assert_eq!(
desktop_new_window_external_url(&external_url),
Some("https://example.com/share".to_string())
);
let same_origin_url = tauri::Url::parse("https://app.genarrative.world/works/detail")
.expect("same-origin desktop new window url");
assert_eq!(desktop_new_window_external_url(&same_origin_url), None);
let unsafe_url =
tauri::Url::parse("javascript:alert(1)").expect("unsafe desktop new window url");
assert_eq!(desktop_new_window_external_url(&unsafe_url), None);
}
}
@@ -97,10 +97,32 @@ pub(crate) fn should_replay_desktop_webview_state_on_page_load(event: PageLoadEv
event == PageLoadEvent::Finished
}
pub(crate) fn log_desktop_webview_state_replay_result(
label: &'static str,
result: tauri::Result<()>,
) -> bool {
match result {
Ok(()) => true,
Err(error) => {
eprintln!("desktop webview state replay failed for {label}: {error}");
false
}
}
}
pub(crate) fn replay_desktop_webview_state(window: &WebviewWindow) {
let _ = emit_current_desktop_lifecycle_event(window);
let _ = register_desktop_network_events(window);
let _ = register_desktop_navigation_events(window);
log_desktop_webview_state_replay_result(
"app.lifecycle",
emit_current_desktop_lifecycle_event(window),
);
log_desktop_webview_state_replay_result(
"network.statusChanged",
register_desktop_network_events(window),
);
log_desktop_webview_state_replay_result(
"navigation.canGoBack",
register_desktop_navigation_events(window),
);
}
#[cfg(test)]
@@ -117,6 +139,15 @@ mod tests {
));
}
#[test]
fn desktop_state_replay_result_reports_success_and_failure() {
assert!(log_desktop_webview_state_replay_result("network", Ok(())));
assert!(!log_desktop_webview_state_replay_result(
"navigation",
Err(tauri::Error::AssetNotFound("navigation".to_string()))
));
}
#[test]
fn desktop_lifecycle_payload_maps_hidden_and_minimized_to_background() {
assert_eq!(
@@ -1,12 +1,14 @@
pub(crate) use crate::shell::file_drop::register_desktop_file_drop_events;
pub(crate) use crate::shell::lifecycle::{
emit_current_desktop_lifecycle_event, register_desktop_lifecycle_events,
replay_desktop_webview_state, should_replay_desktop_webview_state_on_page_load,
emit_current_desktop_lifecycle_event, log_desktop_webview_state_replay_result,
register_desktop_lifecycle_events, replay_desktop_webview_state,
should_replay_desktop_webview_state_on_page_load,
};
pub(crate) use crate::shell::navigation::{
normalize_external_url, normalize_native_page_url, open_desktop_external_navigation,
open_normalized_desktop_external_url, register_desktop_navigation_events,
should_allow_desktop_webview_download, should_allow_desktop_webview_navigation,
desktop_external_navigation_url, normalize_external_url, normalize_native_page_url,
open_desktop_external_navigation, open_normalized_desktop_external_url,
register_desktop_navigation_events, should_allow_desktop_webview_download,
should_allow_desktop_webview_navigation,
};
pub(crate) use crate::shell::network::{
register_desktop_network_events, resolve_desktop_network_status,
@@ -2851,3 +2851,10 @@
- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs``apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 统一返回 `HostBridgeResponse`,各自承接 payload 校验、系统调用、成功响应和失败响应映射;`dispatch.rs` 只保留 method 委托。桌面壳配置检查会拒绝 `dispatch.rs` 重新对这两个 method 做 `match` 或包装 `ok(true)`
- 影响范围:`apps/desktop-shell/src-tauri/src/host_bridge/badge.rs``apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs``apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs``apps/desktop-shell/scripts/check-config.mjs``docs/project-memory/shared-memory/decision-log.md`
- 验证方式:`npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-20 桌面壳 HostBridge 事件注册失败不可静默
- 背景:Tauri 桌面壳已经声明 `host.events``app.lifecycle``network.statusChanged``navigation.canGoBack`,H5 会据此订阅生命周期、网络和返回栈事件;如果 WebView 事件脚本注册失败仍被静默忽略,H5 会误以为宿主能力可用。
- 决策:桌面壳启动阶段安装 `network.statusChanged``navigation.canGoBack` 脚本失败时直接阻断启动;生命周期首发和页面加载重放阶段的 `app.lifecycle``network.statusChanged``navigation.canGoBack` 失败必须通过统一 helper 记录日志,不允许 `let _ = register_desktop_*``let _ = emit_current_*` 静默吞错。配置检查反查该错误处理路径。
- 影响范围:`apps/desktop-shell/src-tauri/src/app.rs``apps/desktop-shell/src-tauri/src/shell/lifecycle.rs``apps/desktop-shell/scripts/check-config.mjs`、宿主壳能力统一协议文档。
- 验证方式:`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml``npm run check:native-shells``npm run check:encoding``git diff --check`
@@ -58,6 +58,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳和 Tauri 桌面壳都声明 `host.events`,表示宿主会通过 HostBridge message 派发事件;其中 Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发同名事件。桌面壳不会把 hidden、minimized 或 tray 扩成新的 `state`,而是读取 `is_visible()``is_minimized()``is_focused()` 后统一归一为 `active` / `inactive` / `background`,并只把 `hidden``minimized``focused``blurred` 放进 `nativeState` 用于排障。`host.events` 不作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。H5 统一通过 `useHostLifecycleActive()` 把宿主状态折算为运行态可播放状态;WebAudio 背景音乐和固定玩法 `<audio>` 背景音乐都必须按该状态暂停 / 恢复,宿主进入后台、inactive 或窗口失焦时暂停,回到 active 且 focused 后只在原运行态、音源和用户音量仍允许时恢复。
- `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`:原生 App 宿主的受控网络状态入口。Expo 移动壳通过 `expo-network` 查询并订阅真实系统网络状态;Tauri 桌面壳从 `WEB_APP_ORIGIN` 解析主站 host / port 后按共享契约 `HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS` 做短超时 TCP 可达性查询,并在主 WebView 内监听 `online` / `offline` 注入变化事件。H5 只依赖统一的 `isConnected``isInternetReachable` 和连接类型,不直接读取平台私有网络 API。平台外部生成队列概览通过 `useHostNetworkOnline()` 消费该状态,宿主未声明网络能力时保持原轮询行为,宿主明确离线或不可达时暂停轮询,恢复在线后重新刷新;该状态不替代后端队列事实或生成结果回读。
- `subscribeHostNavigationCanGoBack()` / `useHostNavigationCanGoBack()`:原生 App 宿主的受控返回栈状态入口。H5 只有在宿主同时声明 `host.events``navigation.canGoBack` 时才订阅该事件;Expo 移动壳和 Tauri 桌面壳注入的状态只表示当前 H5 文档路由栈或宿主已归一后的返回状态,不让 H5 读取任意原生 back-forward list。主 App 在原生壳内直达非平台首页、非 runtime 的二级 H5 route 且当前 history state 没有应用导航标记时,会先把当前条目替换为 `/` 返回锚点,再把当前路径连同已保留的宿主 query 推回 history;普通浏览器、小程序、runtime 路由、已有应用 history 或不支持返回栈事件的裁剪壳不触发该补齐。
声明 `host.events``app.lifecycle``network.statusChanged``navigation.canGoBack` 后,宿主壳必须把对应 WebView 事件脚本注册失败显式失败或记录日志;启动阶段无法安装网络 / 返回栈事件时不得继续以半可用状态运行,页面重放阶段不得静默吞掉失败。
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。