Skip to main content

origin_platform/
tray.rs

1//! System-tray contract (G10).
2//!
3//! Products fill the tray at runtime; the host owns the native widget. Like
4//! `NotificationService`, every update is a fire-and-forget call — the
5//! implementation decides how a title or badge is rendered.
6
7use async_trait::async_trait;
8use origin_domain::Result;
9use std::fmt::Debug;
10
11/// A single tray menu entry, product-shaped so the host can build a native menu
12/// from it without knowing the product's ids in advance.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TrayMenuItem {
15    /// Stable identifier, e.g. `"show"`, `"sync_now"`, `"open_settings"`.
16    pub id: String,
17    pub label: String,
18    /// Whether the item is selectable right now.
19    pub enabled: bool,
20}
21
22impl TrayMenuItem {
23    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
24        Self {
25            id: id.into(),
26            label: label.into(),
27            enabled: true,
28        }
29    }
30
31    pub fn disabled(mut self) -> Self {
32        self.enabled = false;
33        self
34    }
35}
36
37/// What the tray icon signals without the user clicking.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TrayBadge {
40    None,
41    /// A simple red dot — "something needs your attention".
42    Attention,
43    /// A count, e.g. unread notifications or active alerts.
44    Count(u32),
45}
46
47/// A product-facing handle to the system tray.
48///
49/// Present only when the product declared `tray = true`; absent otherwise. A
50/// headless or CLI build gets a no-op, so calling `set_title` never panics.
51#[async_trait]
52pub trait TrayService: Debug + Send + Sync + 'static {
53    /// Change the tray tooltip — the text shown on hover.
54    async fn set_title(&self, title: &str) -> Result<()>;
55
56    /// Replace the badge on the tray icon.
57    async fn set_badge(&self, badge: TrayBadge) -> Result<()>;
58
59    /// Replace the menu. Items are shown in the order given.
60    ///
61    /// The host rebuilds the native menu from these items and maps selection
62    /// to a host-specific handler — the product never sees a pointer or a
63    /// platform menu object.
64    async fn set_menu(&self, items: Vec<TrayMenuItem>) -> Result<()>;
65}
66
67/// Does nothing. For headless runs, CLI builds, and tests.
68#[derive(Debug, Clone, Copy, Default)]
69pub struct NoopTrayService;
70
71#[async_trait]
72impl TrayService for NoopTrayService {
73    async fn set_title(&self, title: &str) -> Result<()> {
74        tracing::debug!(title, "tray title — dropped (noop service)");
75        Ok(())
76    }
77
78    async fn set_badge(&self, badge: TrayBadge) -> Result<()> {
79        tracing::debug!(?badge, "tray badge — dropped (noop service)");
80        Ok(())
81    }
82
83    async fn set_menu(&self, items: Vec<TrayMenuItem>) -> Result<()> {
84        tracing::debug!(count = items.len(), "tray menu — dropped (noop service)");
85        Ok(())
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[tokio::test]
94    async fn the_noop_service_accepts_everything_without_error() {
95        let tray: &dyn TrayService = &NoopTrayService;
96
97        tray.set_title("demo").await.unwrap();
98        tray.set_badge(TrayBadge::Count(3)).await.unwrap();
99        tray.set_menu(vec![
100            TrayMenuItem::new("show", "Show window"),
101            TrayMenuItem::new("quit", "Quit"),
102        ])
103        .await
104        .unwrap();
105    }
106}