1use std::collections::HashSet;
10use std::sync::{Arc, Mutex};
11
12use serde::{Deserialize, Serialize};
13use tauri::image::Image;
14use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
15use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
16use tauri::{AppHandle, Emitter, Manager, Wry};
17
18use crate::menu::MenuEntry;
19use crate::{ShellState, EVENT_TRAY};
20
21pub const TRAY_ID: &str = "rk-shell-tray";
22pub const ID_SHOW: &str = "rk.tray.show";
23pub const ID_QUIT: &str = "rk.tray.quit";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum LeftClick {
28 ToggleWindow,
30 ShowMenu,
32 Show,
34}
35
36#[derive(Debug, Clone)]
37pub struct TraySpec {
38 pub tooltip: String,
39 pub icon_png: Option<Vec<u8>>,
41 pub template: bool,
43 pub items: Vec<MenuEntry>,
44 pub left_click: LeftClick,
45 pub show_label: String,
46 pub quit_label: String,
47}
48
49impl Default for TraySpec {
50 fn default() -> Self {
51 Self {
52 tooltip: String::new(),
53 icon_png: None,
54 template: false,
55 items: Vec::new(),
56 left_click: if cfg!(target_os = "macos") {
57 LeftClick::ShowMenu
58 } else {
59 LeftClick::ToggleWindow
60 },
61 show_label: "Show Window".into(),
62 quit_label: "Quit".into(),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct TrayUpdate {
70 pub tooltip: Option<String>,
71 pub title: Option<String>,
73 pub icon_png: Option<Vec<u8>>,
74 pub items: Option<Vec<MenuEntry>>,
75}
76
77fn s<E: std::fmt::Display>(e: E) -> String {
78 e.to_string()
79}
80
81fn build_menu(
82 app: &AppHandle<Wry>,
83 spec_items: &[MenuEntry],
84 show: &str,
85 quit: &str,
86) -> Result<(Menu<Wry>, HashSet<String>), String> {
87 let ids: HashSet<String> = spec_items
88 .iter()
89 .filter_map(|e| {
90 if let MenuEntry::Item { id, .. } = e {
91 Some(id.clone())
92 } else {
93 None
94 }
95 })
96 .collect();
97 let menu = Menu::new(app).map_err(s)?;
98 menu.append(&MenuItem::with_id(app, ID_SHOW, show, true, None::<&str>).map_err(s)?)
99 .map_err(s)?;
100 if !spec_items.is_empty() {
101 menu.append(&PredefinedMenuItem::separator(app).map_err(s)?)
102 .map_err(s)?;
103 }
104 for e in spec_items {
105 match e {
106 MenuEntry::Separator => menu
107 .append(&PredefinedMenuItem::separator(app).map_err(s)?)
108 .map_err(s)?,
109 MenuEntry::Item {
110 id,
111 label,
112 accelerator,
113 enabled,
114 } => {
115 menu.append(
116 &MenuItem::with_id(app, id, label, *enabled, accelerator.as_deref())
117 .map_err(s)?,
118 )
119 .map_err(s)?;
120 }
121 }
122 }
123 menu.append(&PredefinedMenuItem::separator(app).map_err(s)?)
124 .map_err(s)?;
125 menu.append(&MenuItem::with_id(app, ID_QUIT, quit, true, None::<&str>).map_err(s)?)
126 .map_err(s)?;
127 Ok((menu, ids))
128}
129
130pub struct TrayRuntime {
131 pub icon: Mutex<Option<TrayIcon<Wry>>>,
132 pub ids: Mutex<HashSet<String>>,
135 pub show_label: String,
136 pub quit_label: String,
137}
138
139pub fn install(
140 app: &AppHandle<Wry>,
141 spec: &TraySpec,
142 main_label: &str,
143) -> Result<TrayIcon<Wry>, String> {
144 let (menu, ids) = build_menu(app, &spec.items, &spec.show_label, &spec.quit_label)?;
145 let mut b = TrayIconBuilder::with_id(TRAY_ID)
146 .menu(&menu)
147 .tooltip(&spec.tooltip)
148 .show_menu_on_left_click(spec.left_click == LeftClick::ShowMenu);
149 #[cfg(target_os = "macos")]
150 {
151 b = b.icon_as_template(spec.template);
152 }
153 if let Some(png) = &spec.icon_png {
154 b = b.icon(Image::from_bytes(png).map_err(s)?.to_owned());
155 } else if let Some(icon) = app.default_window_icon() {
156 b = b.icon(icon.clone());
157 }
158 let main = main_label.to_owned();
159 let left = spec.left_click;
160 let tray = b
161 .on_menu_event(|app, event| {
162 let id = event.id.as_ref();
163 match id {
164 ID_SHOW => {
165 crate::windows::reveal_with(app, None, crate::lifecycle::RevealSource::Tray)
166 }
167 ID_QUIT => crate::lifecycle::request_quit(app, "tray"),
168 other
169 if app.state::<ShellState>().tray.as_ref().is_some_and(|t| {
170 t.ids
171 .lock()
172 .unwrap_or_else(|e| e.into_inner())
173 .contains(other)
174 }) =>
175 {
176 let _ = app.emit(EVENT_TRAY, serde_json::json!({"kind": "menu", "id": other}));
177 }
178 _ => {}
179 }
180 })
181 .on_tray_icon_event(move |tray, event| {
182 if let TrayIconEvent::Click {
183 button: MouseButton::Left,
184 button_state: MouseButtonState::Up,
185 ..
186 } = event
187 {
188 let app = tray.app_handle();
189 let _ = app.emit(EVENT_TRAY, serde_json::json!({"kind": "click"}));
190 if left == LeftClick::ToggleWindow {
191 crate::windows::toggle(app, &main);
192 } else if left == LeftClick::Show {
193 crate::windows::reveal_with(app, None, crate::lifecycle::RevealSource::Tray);
194 }
195 }
196 })
197 .build(app)
198 .map_err(s)?;
199 if let Some(rt) = &app.state::<ShellState>().tray {
200 *rt.ids.lock().unwrap_or_else(|e| e.into_inner()) = ids;
201 }
202 Ok(tray)
203}
204
205pub fn update(app: &AppHandle<Wry>, u: &TrayUpdate) -> Result<(), String> {
206 let state = app.state::<ShellState>();
207 let rt = state.tray.as_ref().ok_or("no tray installed")?;
208 let guard = rt.icon.lock().unwrap_or_else(|e| e.into_inner());
209 let tray = guard.as_ref().ok_or("tray not ready")?;
210 if let Some(t) = &u.tooltip {
211 tray.set_tooltip(Some(t.as_str())).map_err(s)?;
212 }
213 #[cfg(target_os = "macos")]
214 if let Some(t) = &u.title {
215 tray.set_title(Some(t.as_str())).map_err(s)?;
216 }
217 if let Some(png) = &u.icon_png {
218 tray.set_icon(Some(Image::from_bytes(png).map_err(s)?.to_owned()))
219 .map_err(s)?;
220 }
221 if let Some(items) = &u.items {
222 let (menu, ids) = build_menu(app, items, &rt.show_label, &rt.quit_label)?;
223 tray.set_menu(Some(menu)).map_err(s)?;
224 *rt.ids.lock().unwrap_or_else(|e| e.into_inner()) = ids;
225 }
226 Ok(())
227}
228
229#[derive(Debug, Clone)]
231pub struct TrayOptions {
232 pub id: String,
233 pub standard_items: bool,
235}
236
237impl Default for TrayOptions {
238 fn default() -> Self {
239 Self {
240 id: TRAY_ID.into(),
241 standard_items: true,
242 }
243 }
244}
245
246impl TrayOptions {
247 pub fn custom_only(id: impl Into<String>) -> Self {
248 Self {
249 id: id.into(),
250 standard_items: false,
251 }
252 }
253
254 pub fn menu_items(&self, spec: &TraySpec) -> Result<Vec<MenuEntry>, String> {
256 if self.id.is_empty() {
257 return Err("tray id must not be empty".into());
258 }
259 let mut ids = HashSet::new();
260 for entry in &spec.items {
261 if let MenuEntry::Item { id, .. } = entry {
262 if id.is_empty() || !ids.insert(id.clone()) {
263 return Err(format!("empty or duplicate tray item id: {id:?}"));
264 }
265 if self.standard_items && matches!(id.as_str(), ID_SHOW | ID_QUIT) {
266 return Err(format!("reserved tray item id: {id}"));
267 }
268 }
269 }
270 if !self.standard_items {
271 return Ok(spec.items.clone());
272 }
273 let item = |id: &str, label: &str| MenuEntry::Item {
274 id: id.into(),
275 label: label.into(),
276 accelerator: None,
277 enabled: true,
278 };
279 let mut rows = vec![item(ID_SHOW, &spec.show_label)];
280 if !spec.items.is_empty() {
281 rows.push(MenuEntry::Separator);
282 }
283 rows.extend(spec.items.clone());
284 rows.push(MenuEntry::Separator);
285 rows.push(item(ID_QUIT, &spec.quit_label));
286 Ok(rows)
287 }
288
289 fn native_id(&self, logical: &str) -> String {
292 format!("rk.tray.{}:{}:{}", self.id.len(), self.id, logical)
293 }
294}
295
296pub type TrayMenuCallback = Arc<dyn Fn(&AppHandle<Wry>, &str) + Send + Sync>;
297
298pub struct TrayHandle {
300 icon: TrayIcon<Wry>,
301 options: TrayOptions,
302 spec: Mutex<TraySpec>,
303 ids: Arc<Mutex<std::collections::HashMap<String, String>>>,
304}
305
306fn independent_menu(
307 app: &AppHandle<Wry>,
308 spec: &TraySpec,
309 options: &TrayOptions,
310) -> Result<(Menu<Wry>, std::collections::HashMap<String, String>), String> {
311 let rows = options.menu_items(spec)?;
312 let menu = Menu::new(app).map_err(s)?;
313 let mut ids = std::collections::HashMap::new();
314 for row in rows {
315 match row {
316 MenuEntry::Separator => menu
317 .append(&PredefinedMenuItem::separator(app).map_err(s)?)
318 .map_err(s)?,
319 MenuEntry::Item {
320 id,
321 label,
322 accelerator,
323 enabled,
324 } => {
325 let native = options.native_id(&id);
326 menu.append(
327 &MenuItem::with_id(app, native.clone(), label, enabled, accelerator.as_deref())
328 .map_err(s)?,
329 )
330 .map_err(s)?;
331 ids.insert(native, id);
332 }
333 }
334 }
335 Ok((menu, ids))
336}
337
338impl TrayHandle {
339 pub fn id(&self) -> &str {
340 &self.options.id
341 }
342 pub fn icon(&self) -> &TrayIcon<Wry> {
343 &self.icon
344 }
345
346 pub fn update(&self, app: &AppHandle<Wry>, update: &TrayUpdate) -> Result<(), String> {
348 let mut spec = self.spec.lock().unwrap_or_else(|e| e.into_inner());
349 if let Some(items) = &update.items {
350 let mut next = spec.clone();
351 next.items = items.clone();
352 let (menu, ids) = independent_menu(app, &next, &self.options)?;
353 self.icon.set_menu(Some(menu)).map_err(s)?;
354 *self.ids.lock().unwrap_or_else(|e| e.into_inner()) = ids;
355 *spec = next;
356 }
357 if let Some(tooltip) = &update.tooltip {
358 self.icon.set_tooltip(Some(tooltip.as_str())).map_err(s)?;
359 }
360 #[cfg(target_os = "macos")]
361 if let Some(title) = &update.title {
362 self.icon.set_title(Some(title.as_str())).map_err(s)?;
363 }
364 if let Some(png) = &update.icon_png {
365 self.icon
366 .set_icon(Some(Image::from_bytes(png).map_err(s)?.to_owned()))
367 .map_err(s)?;
368 }
369 Ok(())
370 }
371}
372
373pub fn install_with_options(
377 app: &AppHandle<Wry>,
378 spec: &TraySpec,
379 main_label: &str,
380 options: TrayOptions,
381 on_menu: Option<TrayMenuCallback>,
382) -> Result<TrayHandle, String> {
383 if app.tray_by_id(&options.id).is_some() {
384 return Err(format!("tray already installed: {}", options.id));
385 }
386 let (menu, initial_ids) = independent_menu(app, spec, &options)?;
387 let ids = Arc::new(Mutex::new(initial_ids));
388 let routed_ids = ids.clone();
389 let tray_id = options.id.clone();
390 let click_id = options.id.clone();
391 let standard = options.standard_items;
392 let left = spec.left_click;
393 let main = main_label.to_owned();
394 let mut builder = TrayIconBuilder::with_id(options.id.clone())
395 .menu(&menu)
396 .tooltip(&spec.tooltip)
397 .show_menu_on_left_click(left == LeftClick::ShowMenu);
398 #[cfg(target_os = "macos")]
399 {
400 builder = builder.icon_as_template(spec.template);
401 }
402 if let Some(png) = &spec.icon_png {
403 builder = builder.icon(Image::from_bytes(png).map_err(s)?.to_owned());
404 } else if let Some(icon) = app.default_window_icon() {
405 builder = builder.icon(icon.clone());
406 }
407 let icon = builder
408 .on_menu_event(move |app, event| {
409 let logical = routed_ids
410 .lock()
411 .unwrap_or_else(|e| e.into_inner())
412 .get(event.id.as_ref())
413 .cloned();
414 if let Some(id) = logical {
415 match id.as_str() {
416 ID_SHOW if standard => {
417 crate::windows::reveal_with(app, None, crate::lifecycle::RevealSource::Tray)
418 }
419 ID_QUIT if standard => crate::lifecycle::request_quit(app, "tray"),
420 _ => {
421 if let Some(callback) = &on_menu {
422 callback(app, &id);
423 }
424 let _ = app.emit(
425 EVENT_TRAY,
426 serde_json::json!({"kind": "menu", "id": id, "trayId": tray_id}),
427 );
428 }
429 }
430 }
431 })
432 .on_tray_icon_event(move |tray, event| {
433 if matches!(
434 event,
435 TrayIconEvent::Click {
436 button: MouseButton::Left,
437 button_state: MouseButtonState::Up,
438 ..
439 }
440 ) {
441 let app = tray.app_handle();
442 let _ = app.emit(
443 EVENT_TRAY,
444 serde_json::json!({"kind": "click", "trayId": click_id}),
445 );
446 if left == LeftClick::ToggleWindow {
447 crate::windows::toggle(app, &main);
448 } else if left == LeftClick::Show {
449 crate::windows::reveal_with(app, None, crate::lifecycle::RevealSource::Tray);
450 }
451 }
452 })
453 .build(app)
454 .map_err(s)?;
455 Ok(TrayHandle {
456 icon,
457 options,
458 spec: Mutex::new(spec.clone()),
459 ids,
460 })
461}
462
463#[cfg(test)]
464mod independent_tests {
465 use super::*;
466
467 #[test]
468 fn native_ids_are_unambiguous_and_separate_from_legacy_ids() {
469 let a = TrayOptions::custom_only("x:y");
470 let b = TrayOptions::custom_only("x");
471 assert_ne!(a.native_id("z"), b.native_id("y:z"));
472 assert_ne!(a.native_id(ID_SHOW), ID_SHOW);
473 assert_eq!(a.native_id("stop"), a.native_id("stop"));
474 }
475}