Skip to main content

origin_notifications_tauri/
lib.rs

1//! Native notifications via `tauri-plugin-notification`.
2//!
3//! This is the concrete side of [`origin_platform::NotificationService`] — the only
4//! place in the notification path that knows Tauri exists (ADR-0001).
5
6use async_trait::async_trait;
7use origin_domain::{AppError, Result};
8use origin_platform::{Notification, NotificationService, Urgency};
9use tauri::{AppHandle, Runtime};
10use tauri_plugin_notification::{NotificationExt, PermissionState};
11
12#[derive(Debug, Clone)]
13pub struct TauriNotificationService<R: Runtime> {
14    app: AppHandle<R>,
15}
16
17impl<R: Runtime> TauriNotificationService<R> {
18    pub fn new(app: AppHandle<R>) -> Self {
19        Self { app }
20    }
21
22    /// Ask the OS for permission if it has not been decided yet.
23    ///
24    /// Returns whether notifications may be shown.
25    fn ensure_permission(&self) -> Result<bool> {
26        let state = self
27            .app
28            .notification()
29            .permission_state()
30            .map_err(|error| AppError::internal(format!("notification permission: {error}")))?;
31
32        let state = match state {
33            PermissionState::Prompt | PermissionState::PromptWithRationale => self
34                .app
35                .notification()
36                .request_permission()
37                .map_err(|error| {
38                    AppError::internal(format!("notification permission request: {error}"))
39                })?,
40            decided => decided,
41        };
42
43        Ok(matches!(state, PermissionState::Granted))
44    }
45}
46
47#[async_trait]
48impl<R: Runtime> NotificationService for TauriNotificationService<R> {
49    async fn notify(&self, notification: Notification) -> Result<()> {
50        // A user who declined notifications is not an error condition — the caller
51        // (a sync run, an alert) must carry on regardless.
52        if !self.ensure_permission()? {
53            tracing::debug!(
54                title = %notification.title,
55                "notification suppressed: permission not granted"
56            );
57            return Ok(());
58        }
59
60        let mut builder = self.app.notification().builder().title(&notification.title);
61
62        if let Some(body) = &notification.body {
63            builder = builder.body(body);
64        }
65        if let Some(tag) = &notification.tag {
66            // Replaces an earlier notification with the same tag instead of stacking.
67            builder = builder.group(tag);
68        }
69        if notification.urgency == Urgency::Critical {
70            builder = builder.sound("default");
71        }
72
73        builder
74            .show()
75            .map_err(|error| AppError::internal(format!("cannot show notification: {error}")))
76    }
77}