Files
Genarrative/apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs
T
kdletters 4d84d76092 收口桌面壳通知诊断日志
桌面壳本地通知失败只记录固定阶段标签

扩展桌面壳配置门禁禁止通知日志输出插件错误细节

补充宿主壳方案和共享决策中的通知诊断边界
2026-06-21 14:08:59 +08:00

289 lines
9.4 KiB
Rust

use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
use serde_json::json;
use serde_json::Value;
use tauri_plugin_notification::{NotificationExt, PermissionState};
const HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION: &str = "delivered_to_system";
const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80;
const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240;
fn normalize_plain_text(
value: Option<&str>,
max_length: usize,
required: bool,
) -> Option<Option<String>> {
let Some(value) = value else {
return if required { None } else { Some(None) };
};
if value.chars().any(char::is_control) {
return None;
}
let text = value.split_whitespace().collect::<Vec<_>>().join(" ");
if text.is_empty() {
return if required { None } else { Some(None) };
}
Some(Some(text.chars().take(max_length).collect()))
}
fn local_notification_payload(
request: &HostBridgeRequest,
) -> Result<(String, Option<String>), HostBridgeResponse> {
let payload = request
.payload
.as_ref()
.ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?;
let title = match normalize_plain_text(
payload.get("title").and_then(Value::as_str),
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
true,
) {
Some(Some(title)) => title,
_ => {
return Err(failed(
request.id.clone(),
"invalid_request",
"title is required",
))
}
};
let body = match normalize_plain_text(
payload.get("body").and_then(Value::as_str),
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
false,
) {
Some(body) => body,
None => {
return Err(failed(
request.id.clone(),
"invalid_request",
"body is invalid",
))
}
};
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
}
}
}
fn desktop_notification_delivered_to_system_result() -> serde_json::Value {
json!({"action": HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION})
}
fn notification_permission_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(
request.id.clone(),
"host_error",
"notification permission unavailable",
)
}
fn notification_delivery_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(
request.id.clone(),
"host_error",
"notification delivery unavailable",
)
}
fn log_desktop_notification_failure(label: &str) -> bool {
eprintln!("desktop notification failed for {label}");
false
}
pub(crate) fn show_desktop_local_notification(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> 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| {
log_desktop_notification_failure("permission.state");
notification_permission_unavailable_response(request)
});
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| {
log_desktop_notification_failure("permission.request");
notification_permission_unavailable_response(request)
});
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 failed(
request.id.clone(),
"host_error",
"notification permission denied",
);
}
let mut notification = app.notification().builder().title(title);
if let Some(body) = body {
notification = notification.body(body);
}
match notification.show() {
Ok(()) => ok(
request.id.clone(),
desktop_notification_delivered_to_system_result(),
),
Err(_error) => {
log_desktop_notification_failure("delivery.show");
notification_delivery_unavailable_response(request)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
use serde_json::json;
#[test]
fn local_notification_payload_is_normalized() {
let mut request = request("notification.showLocal");
request.payload = Some(json!({
"title": " 生成完成 ",
"body": " 作品已准备好 可以试玩 "
}));
let (title, body) = local_notification_payload(&request).expect("payload");
assert_eq!(title, "生成完成");
assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩"));
}
#[test]
fn local_notification_payload_truncates_to_shared_contract_limits() {
let mut request = request("notification.showLocal");
request.payload = Some(json!({
"title": "a".repeat(90),
"body": "b".repeat(250)
}));
let (title, body) = local_notification_payload(&request).expect("payload");
let expected_body = "b".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH);
assert_eq!(title, "a".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH));
assert_eq!(body.as_deref(), Some(expected_body.as_str()));
}
#[test]
fn local_notification_success_result_reports_system_delivery() {
assert_eq!(
desktop_notification_delivered_to_system_result(),
json!({"action": "delivered_to_system"})
);
}
#[test]
fn notification_permission_unavailable_response_is_stable() {
let request = request("notification.showLocal");
let response = notification_permission_unavailable_response(&request);
let error = response.error.expect("error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "notification permission unavailable");
}
#[test]
fn notification_delivery_unavailable_response_is_stable() {
let request = request("notification.showLocal");
let response = notification_delivery_unavailable_response(&request);
let error = response.error.expect("error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "notification delivery unavailable");
}
#[test]
fn notification_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_notification_failure("permission.state"));
assert!(!log_desktop_notification_failure("permission.request"));
assert!(!log_desktop_notification_failure("delivery.show"));
}
#[test]
fn notification_failures_log_stable_label_only() {
assert!(!log_desktop_notification_failure("delivery.show"));
}
#[test]
fn local_notification_payload_rejects_empty_and_control_text() {
let mut empty = request("notification.showLocal");
empty.payload = Some(json!({
"title": " "
}));
let response = local_notification_payload(&empty).expect_err("empty title");
assert_eq!(response.error.expect("error").code, "invalid_request");
let mut control = request("notification.showLocal");
control.payload = Some(json!({
"title": "生成\n完成"
}));
let response = local_notification_payload(&control).expect_err("control title");
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
);
}
}