桌面壳补强通知权限门禁

发送本地通知前检查并请求系统通知权限

收紧桌面壳通知插件授权和配置门禁

同步宿主壳协议文档和团队决策记录
This commit is contained in:
2026-06-18 20:08:26 +08:00
parent 686f52fcec
commit 077c37b67c
5 changed files with 79 additions and 5 deletions
+12 -1
View File
@@ -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,
@@ -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!(
@@ -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`。
File diff suppressed because one or more lines are too long
@@ -53,7 +53,7 @@ AI H5 sandbox
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符;Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件,不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5;宿主未声明或读取失败时由 H5 视作失败并保留原流程。个人中心的邀请码和兑换码弹窗只在宿主声明 `clipboard.readText` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。
- `requestHostHapticsImpact()`:原生 App 宿主的受控触觉反馈入口。Expo 移动壳通过 `haptics.impact` 调用 Expo Haptics,只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`,未知值返回 `invalid_request` 且不触发设备反馈;H5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`。
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`,两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符;Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知,Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。当前 H5 只在现有草稿生成任务收口为完成或失败时请求即时本地通知;通知按草稿来源去重,同一草稿重新进入生成中后才允许再次通知,不改变队列状态、弹窗、作品架或后端裁决。
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`,两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符;Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知,Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 先检查系统通知权限,处于 prompt 状态时只在 Rust 侧请求一次权限,最终授权后才发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。当前 H5 只在现有草稿生成任务收口为完成或失败时请求即时本地通知;通知按草稿来源去重,同一草稿重新进入生成中后才允许再次通知,不改变队列状态、弹窗、作品架或后端裁决。
- `setHostAppTitle()`:原生 App 宿主的受控窗口标题入口。H5 主站会按当前平台阶段先同步 `document.title`,再通过 `app.setTitle` 请求宿主窗口标题同步;Tauri 桌面壳支持该能力,Expo 移动壳不声明时静默忽略。
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0-99999` 的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount` 并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明该能力;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。当前 H5 只把“可见作品架里未读的草稿生成完成更新”同步为角标数,同一个草稿有多个恢复 ID 时只计 1,已读、失败、生成中和不可见草稿不计入;宿主不支持或设置失败不改变 H5 红点、作品架或后端状态。
- `reloadHostWebView()`:原生 App 宿主的受控 WebView 刷新入口。H5 只能请求刷新当前承载主站的宿主 WebView;Expo 移动壳调用当前 `react-native-webview` 的 `reload()`,Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload,不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref;成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。`AuthGate` 在登录态从未登录变为已登录、或从已登录变为未登录时优先调用该能力刷新当前容器;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`。