桌面壳补强通知权限门禁
发送本地通知前检查并请求系统通知权限 收紧桌面壳通知插件授权和配置门禁 同步宿主壳协议文档和团队决策记录
This commit is contained in:
@@ -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()`。
|
||||
|
||||
Reference in New Issue
Block a user