Skip to main content

rightkit_tauri_shell/
tray.rs

1//! System tray / menu-bar extra with a dynamic menu.
2//!
3//! Donors: CodeRight `os_shell/tray` (menu rebuilt from a snapshot, typed actions back to the
4//! renderer, never a second authority), HeardRight `tray.rs` (state icons, left click opens the
5//! app, Quit runs the ordered shutdown first). HeardRight's out-of-process macOS menu-bar helper
6//! (a workaround for a Control Center identity bug) is an app packaging choice and stays in
7//! HeardRight; this tray is the in-process item every other app uses.
8
9use 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    /// Left click toggles the main window (Windows/Linux convention).
29    ToggleWindow,
30    /// Left click opens the menu (macOS convention).
31    ShowMenu,
32    /// Left click reveals the main window and never hides it (a tray that only brings the app forward).
33    Show,
34}
35
36#[derive(Debug, Clone)]
37pub struct TraySpec {
38    pub tooltip: String,
39    /// PNG bytes. `None` uses the app's default window icon.
40    pub icon_png: Option<Vec<u8>>,
41    /// Render the icon as a macOS template (monochrome, tinted by the system).
42    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    /// macOS menu-bar title text next to the icon.
72    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    /// Ids of the items currently in the tray menu. Tauri delivers EVERY menu event (including the
133    /// app menu's) to a tray's handler, so only these ids are tray actions.
134    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/// Options for an independently owned tray. Existing `install` & `TraySpec` are unchanged.
230#[derive(Debug, Clone)]
231pub struct TrayOptions {
232    pub id: String,
233    /// Include shell Show/Quit actions. `false` uses exactly `TraySpec::items`.
234    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    /// Resolve visible rows & reject ambiguous ids before allocating native resources.
255    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    // Tauri broadcasts menu events to all trays. Namespace native ids even when logical
290    // ids match across trays, and keep these distinct from legacy shell menu ids.
291    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
298/// Retain this handle to update one tray; ids & callbacks never use the shell's primary tray.
299pub 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    /// Rebuild just this tray's menu. Custom-only mode & logical ids survive updates.
347    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
373/// Install an additional tray without replacing the shell tray. Menu events retain logical
374/// `id` & add `trayId`; `on_menu` also handles native actions without a renderer subscription.
375/// Call during setup/on the main thread. A duplicate tray id returns an error.
376pub 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}