ashpd/desktop/
notification.rs

1//! # Examples
2//!
3//! ```rust,no_run
4//! use std::{thread, time};
5//!
6//! use ashpd::desktop::{
7//!     notification::{Action, Button, Notification, NotificationProxy, Priority},
8//!     Icon,
9//! };
10//! use futures_util::StreamExt;
11//! use zbus::zvariant::Value;
12//!
13//! async fn run() -> ashpd::Result<()> {
14//!     let proxy = NotificationProxy::new().await?;
15//!
16//!     let notification_id = "org.gnome.design.Contrast";
17//!     proxy
18//!         .add_notification(
19//!             notification_id,
20//!             Notification::new("Contrast")
21//!                 .default_action("open")
22//!                 .default_action_target(100)
23//!                 .body("color copied to clipboard")
24//!                 .priority(Priority::High)
25//!                 .icon(Icon::with_names(&["dialog-question-symbolic"]))
26//!                 .button(Button::new("Copy", "copy").target(32))
27//!                 .button(Button::new("Delete", "delete").target(40)),
28//!         )
29//!         .await?;
30//!
31//!     let action = proxy
32//!         .receive_action_invoked()
33//!         .await?
34//!         .next()
35//!         .await
36//!         .expect("Stream exhausted");
37//!     match action.name() {
38//!         "copy" => (),   // Copy something to clipboard
39//!         "delete" => (), // Delete the file
40//!         _ => (),
41//!     };
42//!     println!("{:#?}", action.id());
43//!     println!(
44//!         "{:#?}",
45//!         action.parameter().get(0).unwrap().downcast_ref::<u32>()
46//!     );
47//!
48//!     proxy.remove_notification(notification_id).await?;
49//!     Ok(())
50//! }
51//! ```
52
53use std::{fmt, os::fd::AsFd, str::FromStr};
54
55use futures_util::Stream;
56use serde::{self, Deserialize, Serialize};
57use zbus::zvariant::{DeserializeDict, OwnedValue, SerializeDict, Type, Value};
58
59use super::Icon;
60use crate::{proxy::Proxy, Error};
61
62#[derive(Debug, Clone, PartialEq, Eq, Type)]
63#[zvariant(signature = "s")]
64/// The content of a notification.
65pub enum Category {
66    /// Instant messaging apps message.
67    #[doc(alias = "im.message")]
68    ImMessage,
69    /// Ringing alarm.
70    #[doc(alias = "alarm.ringing")]
71    AlarmRinging,
72    /// Incoming call.
73    #[doc(alias = "call.incoming")]
74    IncomingCall,
75    /// Ongoing call.
76    #[doc(alias = "call.ongoing")]
77    OngoingCall,
78    /// Missed call.
79    #[doc(alias = "call.missed")]
80    MissedCall,
81    /// Extreme weather warning.
82    #[doc(alias = "weather.warning.extreme")]
83    ExtremeWeather,
84    /// Extreme danger broadcast.
85    #[doc(alias = "cellbroadcast.danger.extreme")]
86    CellNetworkExtremeDanger,
87    /// Severe danger broadcast.
88    #[doc(alias = "cellbroadcast.danger.severe")]
89    CellNetworkSevereDanger,
90    /// Amber alert broadcast.
91    #[doc(alias = "cellbroadcast.amber-alert")]
92    CellNetworkAmberAlert,
93    /// Test broadcast.
94    #[doc(alias = "cellbroadcast.test")]
95    CellNetworkBroadcastTest,
96    /// Low battery.
97    #[doc(alias = "os.battery.low")]
98    LowBattery,
99    /// Browser websites notifications.
100    #[doc(alias = "browser.web-notification")]
101    WebNotification,
102    /// Vendor specific.
103    Other(String),
104}
105
106impl Serialize for Category {
107    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
108    where
109        S: serde::Serializer,
110    {
111        let category_str = match self {
112            Self::ImMessage => "im.message",
113            Self::AlarmRinging => "alarm.ringing",
114            Self::IncomingCall => "call.incoming",
115            Self::OngoingCall => "call.ongoing",
116            Self::MissedCall => "call.missed",
117            Self::ExtremeWeather => "weather.warning.extreme",
118            Self::CellNetworkExtremeDanger => "cellbroadcast.danger.extreme",
119            Self::CellNetworkSevereDanger => "cellbroadcast.danger.severe",
120            Self::CellNetworkAmberAlert => "cellbroadcast.amber-alert",
121            Self::CellNetworkBroadcastTest => "cellbroadcast.test",
122            Self::LowBattery => "os.battery.low",
123            Self::WebNotification => "browser.web-notification",
124            Self::Other(other) => other.as_str(),
125        };
126        serializer.serialize_str(category_str)
127    }
128}
129
130impl FromStr for Category {
131    type Err = crate::Error;
132
133    fn from_str(s: &str) -> Result<Self, Self::Err> {
134        match s {
135            "im.message" => Ok(Self::ImMessage),
136            "alarm.ringing" => Ok(Self::AlarmRinging),
137            "call.incoming" => Ok(Self::IncomingCall),
138            "call.ongoing" => Ok(Self::OngoingCall),
139            "call.missed" => Ok(Self::MissedCall),
140            "weather.warning.extreme" => Ok(Self::ExtremeWeather),
141            "cellbroadcast.danger.extreme" => Ok(Self::CellNetworkExtremeDanger),
142            "cellbroadcast.danger.severe" => Ok(Self::CellNetworkSevereDanger),
143            "cellbroadcast.amber-alert" => Ok(Self::CellNetworkAmberAlert),
144            "cellbroadcast.test" => Ok(Self::CellNetworkBroadcastTest),
145            "os.battery.low" => Ok(Self::LowBattery),
146            "browser.web-notification" => Ok(Self::WebNotification),
147            _ => Ok(Self::Other(s.to_owned())),
148        }
149    }
150}
151
152impl<'de> Deserialize<'de> for Category {
153    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
154    where
155        D: serde::Deserializer<'de>,
156    {
157        let category = String::deserialize(deserializer)?;
158        category
159            .parse::<Self>()
160            .map_err(|_e| serde::de::Error::custom("Failed to parse category"))
161    }
162}
163
164#[cfg_attr(feature = "glib", derive(glib::Enum))]
165#[cfg_attr(feature = "glib", enum_type(name = "AshpdPriority"))]
166#[derive(Debug, Copy, Clone, Serialize, PartialEq, Eq, Type)]
167#[zvariant(signature = "s")]
168#[serde(rename_all = "lowercase")]
169/// The notification priority
170pub enum Priority {
171    /// Low.
172    Low,
173    /// Normal.
174    Normal,
175    /// High.
176    High,
177    /// Urgent.
178    Urgent,
179}
180
181impl fmt::Display for Priority {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            Self::Low => write!(f, "Low"),
185            Self::Normal => write!(f, "Normal"),
186            Self::High => write!(f, "High"),
187            Self::Urgent => write!(f, "Urgent"),
188        }
189    }
190}
191
192impl AsRef<str> for Priority {
193    fn as_ref(&self) -> &str {
194        match self {
195            Self::Low => "Low",
196            Self::Normal => "Normal",
197            Self::High => "High",
198            Self::Urgent => "Urgent",
199        }
200    }
201}
202
203impl From<Priority> for &'static str {
204    fn from(d: Priority) -> Self {
205        match d {
206            Priority::Low => "Low",
207            Priority::Normal => "Normal",
208            Priority::High => "High",
209            Priority::Urgent => "Urgent",
210        }
211    }
212}
213
214impl FromStr for Priority {
215    type Err = Error;
216
217    fn from_str(s: &str) -> Result<Self, Self::Err> {
218        match s {
219            "Low" | "low" => Ok(Priority::Low),
220            "Normal" | "normal" => Ok(Priority::Normal),
221            "High" | "high" => Ok(Priority::High),
222            "Urgent" | "urgent" => Ok(Priority::Urgent),
223            _ => Err(Error::ParseError("Failed to parse priority, invalid value")),
224        }
225    }
226}
227
228#[cfg_attr(feature = "glib", derive(glib::Enum))]
229#[cfg_attr(feature = "glib", enum_type(name = "AshpdNotificationDisplayHint"))]
230#[derive(Debug, Copy, Clone, PartialEq, Eq, Type)]
231#[zvariant(signature = "s")]
232/// Ways to display a notification.
233pub enum DisplayHint {
234    /// Transient.
235    #[doc(alias = "transient")]
236    Transient,
237    /// Tray.
238    #[doc(alias = "tray")]
239    Tray,
240    /// Persistent.
241    #[doc(alias = "persistent")]
242    Persistent,
243    /// Hide on lockscreen.
244    #[doc(alias = "hide-on-lockscreen")]
245    HideOnLockScreen,
246    /// Enable speakerphone.
247    #[doc(alias = "hide-content-on-lockscreen")]
248    HideContentOnLockScreen,
249    /// Show as new.
250    #[doc(alias = "show-as-new")]
251    ShowAsNew,
252}
253
254impl Serialize for DisplayHint {
255    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
256    where
257        S: serde::Serializer,
258    {
259        let purpose = match self {
260            Self::Transient => "transient",
261            Self::Tray => "tray",
262            Self::Persistent => "persistent",
263            Self::HideOnLockScreen => "hide-on-lockscreen",
264            Self::HideContentOnLockScreen => "hide-content-on-lockscreen",
265            Self::ShowAsNew => "show-as-new",
266        };
267        serializer.serialize_str(purpose)
268    }
269}
270
271#[derive(SerializeDict, Type, Debug)]
272/// A notification
273#[zvariant(signature = "dict")]
274pub struct Notification {
275    /// User-visible string to display as the title.
276    title: String,
277    /// User-visible string to display as the body.
278    body: Option<String>,
279    #[zvariant(rename = "markup-body")]
280    markup_body: Option<String>,
281    /// Serialized icon (e.g using gio::Icon::serialize).
282    icon: Option<Icon>,
283    /// The priority for the notification.
284    priority: Option<Priority>,
285    /// Name of an action that is exported by the application.
286    /// This action will be activated when the user clicks on the notification.
287    #[zvariant(rename = "default-action")]
288    default_action: Option<String>,
289    /// Target parameter to send along when activating the default action.
290    #[zvariant(rename = "default-action-target")]
291    default_action_target: Option<OwnedValue>,
292    /// Array of buttons to add to the notification.
293    buttons: Option<Vec<Button>>,
294    category: Option<Category>,
295    #[zvariant(rename = "display-hint")]
296    display_hints: Option<Vec<DisplayHint>>,
297    sound: Option<OwnedValue>,
298}
299
300impl Notification {
301    /// Create a new notification.
302    ///
303    /// # Arguments
304    ///
305    /// * `title` - the notification title.
306    pub fn new(title: &str) -> Self {
307        Self {
308            title: title.to_owned(),
309            body: None,
310            markup_body: None,
311            priority: None,
312            icon: None,
313            default_action: None,
314            default_action_target: None,
315            buttons: None,
316            category: None,
317            display_hints: None,
318            sound: None,
319        }
320    }
321
322    /// Sets the notification body.
323    #[must_use]
324    pub fn body<'a>(mut self, body: impl Into<Option<&'a str>>) -> Self {
325        self.body = body.into().map(ToOwned::to_owned);
326        self
327    }
328
329    /// Same as [`Notification::body`] but supports markup formatting.
330    #[must_use]
331    pub fn markup_body<'a>(mut self, markup_body: impl Into<Option<&'a str>>) -> Self {
332        self.markup_body = markup_body.into().map(ToOwned::to_owned);
333        self
334    }
335
336    /// Sets an icon to the notification.
337    #[must_use]
338    pub fn icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
339        self.icon = icon.into();
340        self
341    }
342
343    /// Sets the notification sound.
344    #[must_use]
345    pub fn sound<S>(mut self, sound: impl Into<Option<S>>) -> Self
346    where
347        S: AsFd,
348    {
349        self.sound = sound.into().map(|s| {
350            zbus::zvariant::Value::from(zbus::zvariant::Fd::from(s.as_fd()))
351                .try_to_owned()
352                .unwrap()
353        });
354        self
355    }
356
357    /// Sets the notification category.
358    #[must_use]
359    pub fn category(mut self, category: impl Into<Option<Category>>) -> Self {
360        self.category = category.into();
361        self
362    }
363
364    #[must_use]
365    /// Sets the notification display hints.
366    pub fn display_hint(mut self, hints: impl IntoIterator<Item = DisplayHint>) -> Self {
367        self.display_hints = Some(hints.into_iter().collect());
368        self
369    }
370
371    /// Sets the notification priority.
372    #[must_use]
373    pub fn priority(mut self, priority: impl Into<Option<Priority>>) -> Self {
374        self.priority = priority.into();
375        self
376    }
377
378    /// Sets the default action when the user clicks on the notification.
379    #[must_use]
380    pub fn default_action<'a>(mut self, default_action: impl Into<Option<&'a str>>) -> Self {
381        self.default_action = default_action.into().map(ToOwned::to_owned);
382        self
383    }
384
385    /// Sets a value to be sent in the `action_invoked` signal.
386    #[must_use]
387    pub fn default_action_target<'a, T: Into<Value<'a>>>(
388        mut self,
389        default_action_target: impl Into<Option<T>>,
390    ) -> Self {
391        self.default_action_target = default_action_target
392            .into()
393            .map(|t| t.into().try_to_owned().unwrap());
394        self
395    }
396
397    /// Adds a new button to the notification.
398    #[must_use]
399    pub fn button(mut self, button: Button) -> Self {
400        match self.buttons {
401            Some(ref mut buttons) => buttons.push(button),
402            None => {
403                self.buttons.replace(vec![button]);
404            }
405        };
406        self
407    }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Type)]
411#[zvariant(signature = "s")]
412/// The purpose of a button.
413pub enum ButtonPurpose {
414    /// Instant messaging reply with text.
415    #[doc(alias = "im.reply-with-text")]
416    ImReplyWithText,
417    /// Accept call.
418    #[doc(alias = "call.accept")]
419    CallAccept,
420    /// Decline call.
421    #[doc(alias = "call.decline")]
422    CallDecline,
423    /// Hangup call.
424    #[doc(alias = "call.hang-up")]
425    CallHangup,
426    /// Enable speakerphone.
427    #[doc(alias = "call.enable-speakerphone")]
428    CallEnableSpeakerphone,
429    /// Disable speakerphone.
430    #[doc(alias = "call.disable-speakerphone")]
431    CallDisableSpeakerphone,
432    /// System custom alert.
433    #[doc(alias = "system.custom-alert")]
434    SystemCustomAlert,
435    /// Vendor specific.
436    Other(String),
437}
438
439impl Serialize for ButtonPurpose {
440    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
441    where
442        S: serde::Serializer,
443    {
444        let purpose = match self {
445            Self::ImReplyWithText => "im.reply-with-text",
446            Self::CallAccept => "call.accept",
447            Self::CallDecline => "call.decline",
448            Self::CallHangup => "call.hang-up",
449            Self::CallEnableSpeakerphone => "call.enable-speakerphone",
450            Self::CallDisableSpeakerphone => "call.disable-speakerphone",
451            Self::SystemCustomAlert => "system.custom-alert",
452            Self::Other(other) => other.as_str(),
453        };
454        serializer.serialize_str(purpose)
455    }
456}
457
458impl FromStr for ButtonPurpose {
459    type Err = crate::Error;
460
461    fn from_str(s: &str) -> Result<Self, Self::Err> {
462        match s {
463            "im.reply-with-text" => Ok(Self::ImReplyWithText),
464            "call.accept" => Ok(Self::CallAccept),
465            "call.decline" => Ok(Self::CallDecline),
466            "call.hang-up" => Ok(Self::CallHangup),
467            "call.enable-speakerphone" => Ok(Self::CallEnableSpeakerphone),
468            "call.disable-speakerphone" => Ok(Self::CallDisableSpeakerphone),
469            "system.custom-alert" => Ok(Self::SystemCustomAlert),
470            _ => Ok(Self::Other(s.to_owned())),
471        }
472    }
473}
474
475impl<'de> Deserialize<'de> for ButtonPurpose {
476    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
477    where
478        D: serde::Deserializer<'de>,
479    {
480        let purpose = String::deserialize(deserializer)?;
481        purpose
482            .parse::<Self>()
483            .map_err(|_e| serde::de::Error::custom("Failed to parse purpose"))
484    }
485}
486
487#[derive(SerializeDict, Type, Debug)]
488/// A notification button
489#[zvariant(signature = "dict")]
490pub struct Button {
491    /// User-visible label for the button. Mandatory.
492    label: String,
493    /// Name of an action that is exported by the application. The action will
494    /// be activated when the user clicks on the button.
495    action: String,
496    /// Target parameter to send along when activating the action.
497    target: Option<OwnedValue>,
498    purpose: Option<ButtonPurpose>,
499}
500
501impl Button {
502    /// Create a new notification button.
503    ///
504    /// # Arguments
505    ///
506    /// * `label` - the user visible label of the button.
507    /// * `action` - the action name to be invoked when the user clicks on the
508    ///   button.
509    pub fn new(label: &str, action: &str) -> Self {
510        Self {
511            label: label.to_owned(),
512            action: action.to_owned(),
513            target: None,
514            purpose: None,
515        }
516    }
517
518    /// The value to send with the action name when the button is clicked.
519    #[must_use]
520    pub fn target<'a, T: Into<Value<'a>>>(mut self, target: impl Into<Option<T>>) -> Self {
521        self.target = target.into().map(|t| t.into().try_to_owned().unwrap());
522        self
523    }
524
525    /// Sets the button purpose.
526    #[must_use]
527    pub fn purpose(mut self, purpose: impl Into<Option<ButtonPurpose>>) -> Self {
528        self.purpose = purpose.into();
529        self
530    }
531}
532
533#[derive(Debug, Deserialize, Type)]
534/// An invoked action.
535pub struct Action(String, String, Vec<OwnedValue>);
536
537impl Action {
538    /// Notification ID.
539    pub fn id(&self) -> &str {
540        &self.0
541    }
542
543    /// Action name.
544    pub fn name(&self) -> &str {
545        &self.1
546    }
547
548    /// The parameters passed to the action.
549    pub fn parameter(&self) -> &Vec<OwnedValue> {
550        &self.2
551    }
552}
553
554#[derive(DeserializeDict, Type, Debug, OwnedValue)]
555#[zvariant(signature = "dict")]
556// TODO: figure out why this can't use the enums
557struct SupportedOptions {
558    category: Vec<String>,
559    #[zvariant(rename = "button-purpose")]
560    button_purpose: Vec<String>,
561}
562
563/// The interface lets sandboxed applications send and withdraw notifications.
564///
565/// It is not possible for the application to learn if the notification was
566/// actually presented to the user. Not a portal in the strict sense, since
567/// there is no user interaction.
568///
569/// **Note** in contrast to most other portal requests, notifications are
570/// expected to outlast the running application. If a user clicks on a
571/// notification after the application has exited, it will get activated again.
572///
573/// Notifications can specify actions that can be activated by the user.
574/// Actions whose name starts with 'app.' are assumed to be exported and will be
575/// activated via the ActivateAction() method in the org.freedesktop.Application
576/// interface. Other actions are activated by sending the
577///  `#org.freedeskop.portal.Notification::ActionInvoked` signal to the
578/// application.
579///
580/// Wrapper of the DBus interface: [`org.freedesktop.portal.Notification`](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Notification.html).
581#[derive(Debug)]
582#[doc(alias = "org.freedesktop.portal.Notification")]
583pub struct NotificationProxy<'a>(Proxy<'a>);
584
585impl<'a> NotificationProxy<'a> {
586    /// Create a new instance of [`NotificationProxy`].
587    pub async fn new() -> Result<NotificationProxy<'a>, Error> {
588        let proxy = Proxy::new_desktop("org.freedesktop.portal.Notification").await?;
589        Ok(Self(proxy))
590    }
591
592    /// Signal emitted when a particular action is invoked.
593    ///
594    /// # Specifications
595    ///
596    /// See also [`ActionInvoked`](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Notification.html#org-freedesktop-portal-notification-actioninvoked).
597    #[doc(alias = "ActionInvoked")]
598    #[doc(alias = "XdpPortal::notification-action-invoked")]
599    pub async fn receive_action_invoked(&self) -> Result<impl Stream<Item = Action>, Error> {
600        self.0.signal("ActionInvoked").await
601    }
602
603    /// Sends a notification.
604    ///
605    /// The ID can be used to later withdraw the notification.
606    /// If the application reuses the same ID without withdrawing, the
607    /// notification is replaced by the new one.
608    ///
609    /// # Arguments
610    ///
611    /// * `id` - Application-provided ID for this notification.
612    /// * `notification` - The notification.
613    ///
614    /// # Specifications
615    ///
616    /// See also [`AddNotification`](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Notification.html#org-freedesktop-portal-notification-addnotification).
617    #[doc(alias = "AddNotification")]
618    #[doc(alias = "xdp_portal_add_notification")]
619    pub async fn add_notification(
620        &self,
621        id: &str,
622        notification: Notification,
623    ) -> Result<(), Error> {
624        self.0.call("AddNotification", &(id, notification)).await
625    }
626
627    /// Withdraws a notification.
628    ///
629    /// # Arguments
630    ///
631    /// * `id` - Application-provided ID for this notification.
632    ///
633    /// # Specifications
634    ///
635    /// See also [`RemoveNotification`](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Notification.html#org-freedesktop-portal-notification-removenotification).
636    #[doc(alias = "RemoveNotification")]
637    #[doc(alias = "xdp_portal_remove_notification")]
638    pub async fn remove_notification(&self, id: &str) -> Result<(), Error> {
639        self.0.call("RemoveNotification", &(id)).await
640    }
641
642    /// Supported options by the notifications server.
643    ///
644    /// # Required version
645    ///
646    /// The method requires the 2nd version implementation of the portal and
647    /// would fail with [`Error::RequiresVersion`] otherwise.
648    ///
649    /// # Specifications
650    ///
651    /// See also [`SupportedOptions`](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Notification.html#org-freedesktop-portal-notification-supportedoptions).
652    pub async fn supported_options(&self) -> Result<(Vec<Category>, Vec<ButtonPurpose>), Error> {
653        let options = self
654            .0
655            .property_versioned::<SupportedOptions>("SupportedOptions", 2)
656            .await?;
657        let categories = options
658            .category
659            .into_iter()
660            .map(|c| Category::from_str(&c).unwrap())
661            .collect();
662        let purposes = options
663            .button_purpose
664            .into_iter()
665            .map(|c| ButtonPurpose::from_str(&c).unwrap())
666            .collect();
667        Ok((categories, purposes))
668    }
669}
670
671impl<'a> std::ops::Deref for NotificationProxy<'a> {
672    type Target = zbus::Proxy<'a>;
673
674    fn deref(&self) -> &Self::Target {
675        &self.0
676    }
677}