From 077c37b67c7e9e62ad08692b2de42ba5b20d88d6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 20:08:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=A3=B3=E8=A1=A5=E5=BC=BA?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=9D=83=E9=99=90=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发送本地通知前检查并请求系统通知权限 收紧桌面壳通知插件授权和配置门禁 同步宿主壳协议文档和团队决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 13 ++++- .../src-tauri/src/host_bridge/dispatch.rs | 58 ++++++++++++++++++- .../shared-memory/decision-log.md | 7 +++ ...€‘ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- ...�前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 3c3d25bc0..2da710a49 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1151,7 +1151,15 @@ const requiredRustHostSnippets = [ '"notification.showLocal"', 'tauri_plugin_dialog::init()', 'tauri_plugin_notification::init()', - 'tauri_plugin_notification::NotificationExt', + 'tauri_plugin_notification::{NotificationExt, PermissionState}', + 'DesktopNotificationPermissionAction', + 'desktop_notification_permission_action', + 'PermissionState::Granted', + 'PermissionState::Denied', + 'PermissionState::Prompt | PermissionState::PromptWithRationale', + 'notification_manager.permission_state()', + 'notification_manager.request_permission()', + '"notification permission denied"', '"copied_to_clipboard"', '"file export cancelled"', '"file import cancelled"', @@ -1247,6 +1255,9 @@ assertSameList( allowedPermissions, 'desktop shell capability permissions', ); +if ((capability.permissions ?? []).some((permission) => permission.startsWith('notification:'))) { + throw new Error('desktop shell must not expose notification plugin commands to H5'); +} assertSameList( extractTauriBuildCommands(buildScript), allowedTauriCommands, diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs index e8b7e894c..9e317470c 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -17,7 +17,7 @@ use serde_json::{json, Value}; use tauri::Manager; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_dialog::DialogExt; -use tauri_plugin_notification::NotificationExt; +use tauri_plugin_notification::{NotificationExt, PermissionState}; use tauri_plugin_opener::OpenerExt; const BADGE_COUNT_MAX: i64 = 99999; @@ -122,6 +122,25 @@ fn local_notification_payload( Ok((title, body)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DesktopNotificationPermissionAction { + Show, + Request, + Reject, +} + +fn desktop_notification_permission_action( + permission_state: PermissionState, +) -> DesktopNotificationPermissionAction { + match permission_state { + PermissionState::Granted => DesktopNotificationPermissionAction::Show, + PermissionState::Denied => DesktopNotificationPermissionAction::Reject, + PermissionState::Prompt | PermissionState::PromptWithRationale => { + DesktopNotificationPermissionAction::Request + } + } +} + pub(crate) fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse { if let Some(response) = validate_request(&request) { return response; @@ -452,6 +471,23 @@ pub(super) async fn execute_host_bridge_request( Ok(payload) => payload, Err(response) => return response, }; + let notification_manager = app.notification(); + let permission_state = match notification_manager.permission_state() { + Ok(permission_state) => permission_state, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + let mut permission_action = desktop_notification_permission_action(permission_state); + if permission_action == DesktopNotificationPermissionAction::Request { + let requested_state = match notification_manager.request_permission() { + Ok(permission_state) => permission_state, + Err(error) => return failed(request.id, "host_error", error.to_string()), + }; + permission_action = desktop_notification_permission_action(requested_state); + } + if permission_action != DesktopNotificationPermissionAction::Show { + return failed(request.id, "host_error", "notification permission denied"); + } + let mut notification = app.notification().builder().title(title); if let Some(body) = body { notification = notification.body(body); @@ -657,6 +693,26 @@ mod tests { assert_eq!(response.error.expect("error").code, "invalid_request"); } + #[test] + fn desktop_notification_permission_state_controls_delivery() { + assert_eq!( + desktop_notification_permission_action(PermissionState::Granted), + DesktopNotificationPermissionAction::Show + ); + assert_eq!( + desktop_notification_permission_action(PermissionState::Denied), + DesktopNotificationPermissionAction::Reject + ); + assert_eq!( + desktop_notification_permission_action(PermissionState::Prompt), + DesktopNotificationPermissionAction::Request + ); + assert_eq!( + desktop_notification_permission_action(PermissionState::PromptWithRationale), + DesktopNotificationPermissionAction::Request + ); + } + #[test] fn window_title_normalization_requires_visible_text() { assert_eq!( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d2b5a3043..e2f95014f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2513,3 +2513,10 @@ - 决策:`apps/mobile-shell/src/shell/webViewPolicy.ts` 统一承接移动壳下载策略,注入脚本阻断下载链接点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click;`ShellApp` 在同源 / 外链分流前调用 `shouldBlockMobileWebViewNavigationRequest(...)`,命中 `blob:`、`data:`、`file:` 或 `filesystem:` 直接拒绝,不进入带完整 HostBridge 的 WebView,也不交给系统外部应用。移动端文件保存仍只能走受控 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method。 - 影响范围:`apps/mobile-shell/src/shell/webViewPolicy.ts`、`apps/mobile-shell/src/shell/webViewPolicy.test.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:test -- src/shell/webViewPolicy.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-18 桌面壳通知权限门禁 + +- 背景:桌面壳已经声明并实现 `notification.showLocal`,且 Tauri capability 只授权 `allow-host-bridge-request`;但 Rust handler 在清洗 payload 后直接调用 `notification.show()`,没有显式检查系统通知权限,也没有把权限拒绝固定成 HostBridge 失败语义。 +- 决策:`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`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index fc39cb77a..dc3d890dd 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -332,7 +332,7 @@ GameBridge 禁止: - 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。 - 验证 macOS / Windows / Linux 至少一条本地 smoke。 -当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端,H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `