diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index ca003807a..8b34e792d 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -2272,6 +2272,8 @@ if ( !desktopHostBridgeDispatchSource.includes( 'set_desktop_app_badge_count(&app, &request)', ) || + desktopHostBridgeDispatchSource.includes('"app.setBadgeCount" => match') || + desktopHostBridgeDispatchSource.includes('Ok(()) => ok(request.id, json!(true))') || desktopHostBridgeDispatchSource.includes('set_badge_count') || desktopHostBridgeDispatchSource.includes('BADGE_COUNT_MAX') ) { @@ -2281,6 +2283,7 @@ for (const snippet of [ 'BADGE_COUNT_MAX', 'fn badge_count_payload', 'set_desktop_app_badge_count', + 'ok(request.id.clone(), json!(true))', '"count must be an integer between 0 and 99999"', ]) { if (!desktopHostBridgeBadgeSource.includes(snippet)) { @@ -2315,6 +2318,8 @@ if ( !desktopHostBridgeDispatchSource.includes( 'show_desktop_local_notification(&app, &request)', ) || + desktopHostBridgeDispatchSource.includes('"notification.showLocal" => match') || + desktopHostBridgeDispatchSource.includes('Ok(()) => ok(request.id, json!(true))') || desktopHostBridgeDispatchSource.includes('app.notification()') || desktopHostBridgeDispatchSource.includes('NotificationExt') || desktopHostBridgeDispatchSource.includes('PermissionState') @@ -2332,6 +2337,7 @@ for (const snippet of [ 'PermissionState::Granted', 'PermissionState::Denied', 'PermissionState::Prompt | PermissionState::PromptWithRationale', + 'ok(request.id.clone(), json!(true))', '"notification permission denied"', 'app.notification().builder()', ]) { diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/badge.rs b/apps/desktop-shell/src-tauri/src/host_bridge/badge.rs index 867a8dfac..5103a797f 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/badge.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/badge.rs @@ -1,4 +1,5 @@ -use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse}; +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use serde_json::json; use serde_json::Value; use tauri::Manager; @@ -32,15 +33,19 @@ fn badge_count_payload(request: &HostBridgeRequest) -> Result, HostB pub(crate) fn set_desktop_app_badge_count( app: &tauri::AppHandle, request: &HostBridgeRequest, -) -> Result<(), HostBridgeResponse> { - let count = badge_count_payload(request)?; +) -> HostBridgeResponse { + let count = match badge_count_payload(request) { + Ok(count) => count, + Err(response) => return response, + }; let Some(window) = app.get_webview_window("main") else { - return Err(failed(request.id.clone(), "host_error", "main window not found")); + return failed(request.id.clone(), "host_error", "main window not found"); }; - window - .set_badge_count(count) - .map_err(|error| failed(request.id.clone(), "host_error", error.to_string())) + match window.set_badge_count(count) { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(error) => failed(request.id.clone(), "host_error", error.to_string()), + } } #[cfg(test)] 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 fd28ca9ec..a5f176f1c 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -63,15 +63,9 @@ pub(super) async fn execute_host_bridge_request( "file.importAudio" => import_desktop_host_bridge_audio_file(&app, &request).await, "file.exportAudio" => export_desktop_host_bridge_audio_file(&app, &request).await, "app.setTitle" => set_desktop_host_bridge_window_title(&app, &request), - "app.setBadgeCount" => match set_desktop_app_badge_count(&app, &request) { - Ok(()) => ok(request.id, json!(true)), - Err(response) => response, - }, + "app.setBadgeCount" => set_desktop_app_badge_count(&app, &request), "network.status" => resolve_desktop_host_bridge_network_status(&request).await, - "notification.showLocal" => match show_desktop_local_notification(&app, &request) { - Ok(()) => ok(request.id, json!(true)), - Err(response) => response, - }, + "notification.showLocal" => show_desktop_local_notification(&app, &request), "share.setTarget" => set_desktop_host_bridge_share_target(&app, &request), "share.open" => open_desktop_host_bridge_share(&app, &request), _ => resolve_host_bridge_request(request), diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs b/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs index 777e9c9a2..effa60605 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs @@ -1,4 +1,5 @@ -use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse}; +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; +use serde_json::json; use serde_json::Value; use tauri_plugin_notification::{NotificationExt, PermissionState}; @@ -86,25 +87,36 @@ fn desktop_notification_permission_action( pub(crate) fn show_desktop_local_notification( app: &tauri::AppHandle, request: &HostBridgeRequest, -) -> Result<(), HostBridgeResponse> { - let (title, body) = local_notification_payload(request)?; +) -> HostBridgeResponse { + let (title, body) = match local_notification_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; let notification_manager = app.notification(); let permission_state = notification_manager .permission_state() - .map_err(|error| failed(request.id.clone(), "host_error", error.to_string()))?; + .map_err(|error| failed(request.id.clone(), "host_error", error.to_string())); + let permission_state = match permission_state { + Ok(permission_state) => permission_state, + Err(response) => return response, + }; let mut permission_action = desktop_notification_permission_action(permission_state); if permission_action == DesktopNotificationPermissionAction::Request { let requested_state = notification_manager .request_permission() - .map_err(|error| failed(request.id.clone(), "host_error", error.to_string()))?; + .map_err(|error| failed(request.id.clone(), "host_error", error.to_string())); + let requested_state = match requested_state { + Ok(requested_state) => requested_state, + Err(response) => return response, + }; permission_action = desktop_notification_permission_action(requested_state); } if permission_action != DesktopNotificationPermissionAction::Show { - return Err(failed( + return failed( request.id.clone(), "host_error", "notification permission denied", - )); + ); } let mut notification = app.notification().builder().title(title); @@ -112,9 +124,10 @@ pub(crate) fn show_desktop_local_notification( notification = notification.body(body); } - notification - .show() - .map_err(|error| failed(request.id.clone(), "host_error", error.to_string())) + match notification.show() { + Ok(()) => ok(request.id.clone(), json!(true)), + Err(error) => failed(request.id.clone(), "host_error", error.to_string()), + } } #[cfg(test)] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 986c9a110..0e969406e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2668,7 +2668,7 @@ ## 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/notifications.rs` 在发送即时本地通知前先调用 `permission_state()`,已授权才发送;处于 prompt / prompt-with-rationale 时只在 Rust 侧调用 `request_permission()` 后复判;最终未授权返回 `host_error: notification permission denied`。`dispatch.rs` 只保留 method 委托和 HostBridge 响应映射。桌面壳仍不把 `notification:*` 插件命令加入 capability permissions,不向 H5 暴露 notification 插件 JS guest API、远程推送、定时提醒或通知 token。 +- 决策:`apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs` 在发送即时本地通知前先调用 `permission_state()`,已授权才发送;处于 prompt / prompt-with-rationale 时只在 Rust 侧调用 `request_permission()` 后复判;最终未授权返回 `host_error: notification permission denied`。`notifications.rs` 统一承接 payload 校验、权限检查、系统通知调用和 HostBridge 成功 / 失败响应映射,`dispatch.rs` 只保留 method 委托。桌面壳仍不把 `notification:*` 插件命令加入 capability permissions,不向 H5 暴露 notification 插件 JS guest API、远程推送、定时提醒或通知 token。 - 影响范围:`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`、宿主壳能力统一协议文档、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`。 @@ -2804,3 +2804,10 @@ - 决策:Expo 移动壳新增 `apps/mobile-shell/src/host-bridge/network.ts`,统一承接 `network.status` HostBridge 查询和成功响应包装,并复用 `src/shell/network.ts`;Tauri 桌面壳新增 `apps/desktop-shell/src-tauri/src/host_bridge/network.rs`,统一承接 `network.status` HostBridge 查询、成功响应和 `host_error` 失败映射,并复用 `shell::network::resolve_desktop_network_status`。两端 `dispatch` 只保留 method 委托,配置检查会拒绝分发层直接导入 shell network、直接执行 `resolve_desktop_network_status`、包装移动网络成功响应或重新映射桌面网络错误。 - 影响范围:`apps/mobile-shell/src/host-bridge/network.ts`、`apps/mobile-shell/src/host-bridge/dispatch.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/src-tauri/src/host_bridge/network.rs`、`apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run mobile-shell:typecheck`、`npm run mobile-shell:test -- src/host-bridge/bridge.test.ts`、`npm run desktop-shell:typecheck`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-20 桌面角标与通知响应边界 + +- 背景:Tauri `app.setBadgeCount` 和 `notification.showLocal` 的系统调用已分别收在 `badge.rs` 与 `notifications.rs`,但 `dispatch.rs` 仍把 `Result<(), HostBridgeResponse>` 映射成 `ok(true)`,继续让分发层知道能力成功响应形状。 +- 决策:`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`。