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 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
62pub struct TauriTrayService<R: Runtime> {
67 app: AppHandle<R>,
68 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#[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}