306 lines
9.6 KiB
Rust
306 lines
9.6 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::{collections::VecDeque, sync::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,
|
|
/// Present for session-scoped events (turn-finished, clarification-requested, failed).
|
|
/// Absent for account/app-scoped events such as an available update,
|
|
/// which has no associated task. `#[serde(default)]` accepts payloads
|
|
/// that omit the key entirely (e.g. `JSON.stringify` drops `undefined`).
|
|
#[serde(default)]
|
|
pub task_id: Option<String>,
|
|
pub session_id: Option<String>,
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum NativeNotificationResult {
|
|
Shown,
|
|
Duplicate,
|
|
PermissionDenied,
|
|
DisplayFailed,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct NativeNotificationState {
|
|
seen_event_ids: Mutex<VecDeque<String>>,
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Event identities this bridge will display. Session-scoped events require
|
|
/// a non-empty task_id (they're gated on task context); the update-available
|
|
/// event has no task and must omit it.
|
|
const SESSION_EVENT_PREFIXES: [&str; 3] = [
|
|
"session.failed:",
|
|
"session.turn_finished:",
|
|
"session.clarification_requested:",
|
|
];
|
|
const UPDATE_AVAILABLE_PREFIX: &str = "system.update_available:";
|
|
|
|
fn validate_request(request: &NativeNotificationRequest) -> Result<(), String> {
|
|
let is_session_event = SESSION_EVENT_PREFIXES
|
|
.iter()
|
|
.any(|prefix| request.event_id.starts_with(prefix));
|
|
let is_update_event = request.event_id.starts_with(UPDATE_AVAILABLE_PREFIX);
|
|
if !is_session_event && !is_update_event {
|
|
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.session_id.as_ref().is_some_and(|id| id.len() > 256)
|
|
|| request.task_id.as_ref().is_some_and(|id| id.len() > 256)
|
|
{
|
|
return Err("invalid native notification payload".to_string());
|
|
}
|
|
if is_session_event {
|
|
match request.task_id.as_deref() {
|
|
Some(id) if !id.is_empty() => {}
|
|
_ => 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 permission = app
|
|
.notification()
|
|
.permission_state()
|
|
.map_err(|err| 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);
|
|
eprintln!("Could not show native notification: {err}");
|
|
return Ok(NativeNotificationResult::DisplayFailed);
|
|
}
|
|
Ok(NativeNotificationResult::Shown)
|
|
}
|
|
|
|
#[cfg(feature = "desktop-runtime")]
|
|
#[tauri::command]
|
|
pub fn get_native_notification_permission(
|
|
app: tauri::AppHandle,
|
|
backend: tauri::State<'_, crate::backend::BackendState>,
|
|
webview: tauri::WebviewWindow,
|
|
) -> Result<tauri::plugin::PermissionState, String> {
|
|
use tauri_plugin_notification::NotificationExt;
|
|
|
|
backend.require_owned_origin(&webview)?;
|
|
app.notification()
|
|
.permission_state()
|
|
.map_err(|err| err.to_string())
|
|
}
|
|
|
|
#[cfg(feature = "desktop-runtime")]
|
|
#[tauri::command]
|
|
pub fn request_native_notification_permission(
|
|
app: tauri::AppHandle,
|
|
backend: tauri::State<'_, crate::backend::BackendState>,
|
|
webview: tauri::WebviewWindow,
|
|
) -> Result<tauri::plugin::PermissionState, String> {
|
|
use tauri_plugin_notification::NotificationExt;
|
|
|
|
backend.require_owned_origin(&webview)?;
|
|
app.notification()
|
|
.request_permission()
|
|
.map_err(|err| err.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn request(event_id: &str, task_id: &str) -> NativeNotificationRequest {
|
|
NativeNotificationRequest {
|
|
event_id: event_id.to_string(),
|
|
title: "Agent needs your answer".to_string(),
|
|
body: "The agent asked a question.".to_string(),
|
|
task_id: Some(task_id.to_string()),
|
|
session_id: Some("session-1".to_string()),
|
|
}
|
|
}
|
|
|
|
fn update_available_request(event_id: &str) -> NativeNotificationRequest {
|
|
NativeNotificationRequest {
|
|
event_id: event_id.to_string(),
|
|
title: "Update available".to_string(),
|
|
body: "Kandev v1.2.3 is available.".to_string(),
|
|
task_id: None,
|
|
session_id: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn event_identity_is_delivered_at_most_once() {
|
|
let state = NativeNotificationState::default();
|
|
let request = request("session.clarification_requested: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_supported_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 bridge_accepts_semantic_session_events() {
|
|
for event_id in [
|
|
"session.turn_finished:turn-1",
|
|
"session.clarification_requested:request-1",
|
|
"session.failed:session-1",
|
|
] {
|
|
assert_eq!(validate_request(&request(event_id, "task-1")), Ok(()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_rejects_retired_waiting_for_input_events() {
|
|
let request = request("session.waiting_for_input:session-1", "task-1");
|
|
|
|
assert_eq!(
|
|
validate_request(&request),
|
|
Err("unsupported native notification event".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_accepts_update_available_events_without_task_id() {
|
|
let request = update_available_request("system.update_available:v1.2.3");
|
|
|
|
assert_eq!(validate_request(&request), Ok(()));
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_rejects_session_events_missing_task_id() {
|
|
let mut request = request("session.clarification_requested:session-1", "task-1");
|
|
request.task_id = None;
|
|
|
|
assert_eq!(
|
|
validate_request(&request),
|
|
Err("invalid native notification payload".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_available_identity_is_delivered_at_most_once() {
|
|
let state = NativeNotificationState::default();
|
|
let request = update_available_request("system.update_available:v1.2.3");
|
|
|
|
assert!(state.claim(&request.event_id));
|
|
assert!(!state.claim(&request.event_id));
|
|
}
|
|
|
|
#[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 display_failure_releases_the_event_identity_for_retry() {
|
|
let state = NativeNotificationState::default();
|
|
let request = request("session.failed:session-1", "task-1");
|
|
|
|
assert!(state.claim(&request.event_id));
|
|
state.release(&request.event_id);
|
|
assert_eq!(
|
|
state.claim_after_permission(&request, true),
|
|
NativeNotificationResult::Shown
|
|
);
|
|
}
|
|
}
|