Skip to main content

origin_tauri/
tray.rs

1use crate::{HostConfig, focus_main_window};
2use async_trait::async_trait;
3use origin_domain::{AppError, Result};
4use origin_events::{EventBus, PlatformEvent, TrayItemSelected};
5use origin_platform::{TrayBadge, TrayMenuItem, TrayService};
6use std::sync::Mutex;
7use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
8use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
9use tauri::{AppHandle, Runtime};
10
11const MENU_SHOW: &str = "origin.show";
12const MENU_QUIT: &str = "origin.quit";
13
14pub(crate) fn install<R: Runtime>(
15    app: &AppHandle<R>,
16    config: &HostConfig,
17    events: EventBus,
18) -> tauri::Result<()> {
19    let show = MenuItem::with_id(app, MENU_SHOW, "Show window", true, None::<&str>)?;
20    let separator = PredefinedMenuItem::separator(app)?;
21    let quit = MenuItem::with_id(app, MENU_QUIT, "Quit", true, None::<&str>)?;
22    let menu = Menu::with_items(app, &[&show, &separator, &quit])?;
23
24    let mut builder = TrayIconBuilder::with_id("origin.tray")
25        .tooltip(&config.tray_tooltip)
26        .menu(&menu)
27        .show_menu_on_left_click(false)
28        .on_menu_event(move |app, event| {
29            let id = event.id().as_ref();
30            match id {
31                MENU_SHOW => focus_main_window(app),
32                MENU_QUIT => app.exit(0),
33                // A product-provided item: publish a typed event rather than call product
34                // code from the host (ARCHITECTURE.md rules 9/10).
35                product_item => {
36                    let _ = events.publish(PlatformEvent::TrayItemSelected(TrayItemSelected {
37                        id: product_item.to_owned(),
38                    }));
39                }
40            }
41        })
42        .on_tray_icon_event(|tray, event| {
43            if let TrayIconEvent::Click {
44                button: MouseButton::Left,
45                button_state: MouseButtonState::Up,
46                ..
47            } = event
48            {
49                focus_main_window(tray.app_handle());
50            }
51        });
52
53    if let Some(icon) = app.default_window_icon() {
54        builder = builder.icon(icon.clone());
55    }
56
57    builder.build(app)?;
58    tracing::debug!("tray installed");
59    Ok(())
60}
61
62/// A [`TrayService`] backed by a running Tauri tray icon.
63///
64/// Generic over the runtime so it is not welded to `Wry` — the mock runtime used in
65/// tests satisfies the same bound, which is what makes the adapter testable (G11).
66pub struct TauriTrayService<R: Runtime> {
67    app: AppHandle<R>,
68    /// The base tooltip, without a badge suffix.
69    base_title: Mutex<String>,
70    badge: Mutex<TrayBadge>,
71}
72
73impl<R: Runtime> std::fmt::Debug for TauriTrayService<R> {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("TauriTrayService").finish_non_exhaustive()
76    }
77}
78
79impl<R: Runtime> TauriTrayService<R> {
80    pub fn new(app: AppHandle<R>) -> Self {
81        Self {
82            app,
83            base_title: Mutex::new(String::new()),
84            badge: Mutex::new(TrayBadge::None),
85        }
86    }
87
88    fn update_tooltip(&self) {
89        if let Some(tray) = self.app.tray_by_id("origin.tray") {
90            let base = self.base_title.lock().unwrap();
91            let badge = *self.badge.lock().unwrap();
92            let full = badge_suffix(badge, &base);
93            let _ = tray.set_tooltip(Some(full));
94        }
95    }
96}
97
98fn badge_suffix(badge: TrayBadge, base: &str) -> String {
99    match badge {
100        TrayBadge::None => base.to_owned(),
101        TrayBadge::Attention => format!("{base} ●"),
102        TrayBadge::Count(n) => format!("{base} ({n})"),
103    }
104}
105
106#[async_trait]
107impl<R: Runtime> TrayService for TauriTrayService<R> {
108    async fn set_title(&self, title: &str) -> Result<()> {
109        *self.base_title.lock().unwrap() = title.to_owned();
110        self.update_tooltip();
111        Ok(())
112    }
113
114    async fn set_badge(&self, badge: TrayBadge) -> Result<()> {
115        *self.badge.lock().unwrap() = badge;
116        self.update_tooltip();
117        tracing::debug!(?badge, "tray badge updated");
118        Ok(())
119    }
120
121    async fn set_menu(&self, items: Vec<TrayMenuItem>) -> Result<()> {
122        let Some(tray) = self.app.tray_by_id("origin.tray") else {
123            return Ok(());
124        };
125
126        let mut menu_items: Vec<MenuItem<R>> = Vec::new();
127        for item in &items {
128            let menu_item =
129                MenuItem::with_id(&self.app, &item.id, &item.label, item.enabled, None::<&str>)
130                    .map_err(|e| {
131                        AppError::internal(format!("tray menu item `{}`: {e}", item.id))
132                    })?;
133            menu_items.push(menu_item);
134        }
135
136        let separator = PredefinedMenuItem::separator(&self.app)
137            .map_err(|e| AppError::internal(format!("tray separator: {e}")))?;
138        let quit = MenuItem::with_id(&self.app, MENU_QUIT, "Quit", true, None::<&str>)
139            .map_err(|e| AppError::internal(format!("Quit item: {e}")))?;
140
141        let refs: Vec<&dyn tauri::menu::IsMenuItem<R>> = {
142            let mut collected: Vec<&dyn tauri::menu::IsMenuItem<R>> = Vec::new();
143            for item in &menu_items {
144                collected.push(item);
145            }
146            collected.push(&separator);
147            collected.push(&quit);
148            collected
149        };
150
151        let menu = Menu::with_items(&self.app, &refs)
152            .map_err(|e| AppError::internal(format!("tray menu: {e}")))?;
153
154        let _ = tray.set_menu(Some(menu));
155        tracing::debug!(count = items.len(), "tray menu updated");
156        Ok(())
157    }
158}
159
160/// Host-wiring tests (G11).
161///
162/// A mock Tauri runtime is started, so the adapter runs against a real `AppHandle`.
163/// There is no tray icon registered in the mock, which is exactly the graceful path
164/// worth pinning: every call must be a no-op, never a panic. Gated off Windows for the
165/// same upstream reason as `src/commands.rs`.
166#[cfg(all(test, not(windows)))]
167mod tests {
168    use super::*;
169
170    #[tokio::test]
171    async fn the_tray_service_degrades_gracefully_without_a_registered_tray() {
172        let app = tauri::test::mock_app();
173        let tray = TauriTrayService::new(app.handle().clone());
174
175        tray.set_title("Origin Demo").await.unwrap();
176        tray.set_badge(TrayBadge::Count(3)).await.unwrap();
177        tray.set_menu(vec![
178            TrayMenuItem::new("show", "Show window"),
179            TrayMenuItem::new("quit", "Quit"),
180        ])
181        .await
182        .unwrap();
183    }
184}