226 lines
6.5 KiB
Rust
226 lines
6.5 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::VecDeque,
|
|
sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Mutex,
|
|
},
|
|
};
|
|
|
|
const MAX_SEEN_EVENT_IDS: usize = 1_024;
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NativeNotificationRequest {
|
|
pub event_id: String,
|
|
pub title: String,
|
|
pub body: String,
|
|
pub task_id: String,
|
|
pub session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum NativeNotificationResult {
|
|
Shown,
|
|
Duplicate,
|
|
PermissionDenied,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct NativeNotificationState {
|
|
seen_event_ids: Mutex<VecDeque<String>>,
|
|
permission_prompt_attempted: AtomicBool,
|
|
}
|
|
|
|
impl NativeNotificationState {
|
|
fn claim(&self, event_id: &str) -> bool {
|
|
let mut seen = self
|
|
.seen_event_ids
|
|
.lock()
|
|
.expect("notification identity mutex poisoned");
|
|
if seen.iter().any(|seen_id| seen_id == event_id) {
|
|
return false;
|
|
}
|
|
if seen.len() == MAX_SEEN_EVENT_IDS {
|
|
seen.pop_front();
|
|
}
|
|
seen.push_back(event_id.to_string());
|
|
true
|
|
}
|
|
|
|
fn release(&self, event_id: &str) {
|
|
self.seen_event_ids
|
|
.lock()
|
|
.expect("notification identity mutex poisoned")
|
|
.retain(|seen_id| seen_id != event_id);
|
|
}
|
|
|
|
fn claim_after_permission(
|
|
&self,
|
|
request: &NativeNotificationRequest,
|
|
permission_granted: bool,
|
|
) -> NativeNotificationResult {
|
|
if !permission_granted {
|
|
return NativeNotificationResult::PermissionDenied;
|
|
}
|
|
if self.claim(&request.event_id) {
|
|
NativeNotificationResult::Shown
|
|
} else {
|
|
NativeNotificationResult::Duplicate
|
|
}
|
|
}
|
|
|
|
fn begin_permission_prompt(&self) -> bool {
|
|
!self
|
|
.permission_prompt_attempted
|
|
.swap(true, Ordering::SeqCst)
|
|
}
|
|
|
|
fn allow_permission_prompt_retry(&self) {
|
|
self.permission_prompt_attempted
|
|
.store(false, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
fn validate_request(request: &NativeNotificationRequest) -> Result<(), String> {
|
|
if !request.event_id.starts_with("session.waiting_for_input:")
|
|
&& !request.event_id.starts_with("session.failed:")
|
|
{
|
|
return Err("unsupported native notification event".to_string());
|
|
}
|
|
if request.event_id.len() > 256
|
|
|| request.title.is_empty()
|
|
|| request.title.len() > 160
|
|
|| request.body.len() > 1000
|
|
|| request.task_id.is_empty()
|
|
|| request.task_id.len() > 256
|
|
|| request.session_id.as_ref().is_some_and(|id| id.len() > 256)
|
|
{
|
|
return Err("invalid native notification payload".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "desktop-runtime")]
|
|
#[tauri::command]
|
|
pub fn show_native_notification(
|
|
app: tauri::AppHandle,
|
|
state: tauri::State<'_, NativeNotificationState>,
|
|
backend: tauri::State<'_, crate::backend::BackendState>,
|
|
webview: tauri::WebviewWindow,
|
|
request: NativeNotificationRequest,
|
|
) -> Result<NativeNotificationResult, String> {
|
|
use tauri::plugin::PermissionState;
|
|
use tauri_plugin_notification::NotificationExt;
|
|
|
|
backend.require_owned_origin(&webview)?;
|
|
validate_request(&request)?;
|
|
let mut permission = app
|
|
.notification()
|
|
.permission_state()
|
|
.map_err(|err| err.to_string())?;
|
|
if permission != PermissionState::Granted
|
|
&& permission != PermissionState::Denied
|
|
&& state.begin_permission_prompt()
|
|
{
|
|
permission = app.notification().request_permission().map_err(|err| {
|
|
state.allow_permission_prompt_retry();
|
|
err.to_string()
|
|
})?;
|
|
}
|
|
let claim_result =
|
|
state.claim_after_permission(&request, permission == PermissionState::Granted);
|
|
if claim_result != NativeNotificationResult::Shown {
|
|
return Ok(claim_result);
|
|
}
|
|
let show_result = app
|
|
.notification()
|
|
.builder()
|
|
.title(&request.title)
|
|
.body(&request.body)
|
|
.show();
|
|
if let Err(err) = show_result {
|
|
state.release(&request.event_id);
|
|
return Err(err.to_string());
|
|
}
|
|
Ok(NativeNotificationResult::Shown)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn request(event_id: &str, task_id: &str) -> NativeNotificationRequest {
|
|
NativeNotificationRequest {
|
|
event_id: event_id.to_string(),
|
|
title: "Task needs your input".to_string(),
|
|
body: "Waiting".to_string(),
|
|
task_id: task_id.to_string(),
|
|
session_id: Some("session-1".to_string()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn event_identity_is_delivered_at_most_once() {
|
|
let state = NativeNotificationState::default();
|
|
let request = request("session.waiting_for_input:session-1", "task-1");
|
|
|
|
assert!(state.claim(&request.event_id));
|
|
assert!(!state.claim(&request.event_id));
|
|
}
|
|
|
|
#[test]
|
|
fn event_identity_cache_is_bounded() {
|
|
let state = NativeNotificationState::default();
|
|
|
|
for index in 0..=MAX_SEEN_EVENT_IDS {
|
|
assert!(state.claim(&format!("session.failed:{index}")));
|
|
}
|
|
|
|
assert!(state.claim("session.failed:0"));
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_rejects_events_outside_the_two_notification_types() {
|
|
let request = request("office.inbox_item:item-1", "task-1");
|
|
|
|
assert_eq!(
|
|
validate_request(&request),
|
|
Err("unsupported native notification event".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn denied_delivery_does_not_claim_the_event() {
|
|
let state = NativeNotificationState::default();
|
|
let request = request("session.failed:session-1", "task-1");
|
|
|
|
assert_eq!(
|
|
state.claim_after_permission(&request, false),
|
|
NativeNotificationResult::PermissionDenied
|
|
);
|
|
assert_eq!(
|
|
state.claim_after_permission(&request, true),
|
|
NativeNotificationResult::Shown
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_prompt_is_attempted_only_once_per_process() {
|
|
let state = NativeNotificationState::default();
|
|
|
|
assert!(state.begin_permission_prompt());
|
|
assert!(!state.begin_permission_prompt());
|
|
}
|
|
|
|
#[test]
|
|
fn permission_prompt_can_retry_after_a_transient_error() {
|
|
let state = NativeNotificationState::default();
|
|
|
|
assert!(state.begin_permission_prompt());
|
|
state.allow_permission_prompt_retry();
|
|
assert!(state.begin_permission_prompt());
|
|
}
|
|
}
|