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::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}
33
34#[derive(Debug, Clone)]
35pub struct TraySpec {
36    pub tooltip: String,
37    /// PNG bytes. `None` uses the app's default window icon.
38    pub icon_png: Option<Vec<u8>>,
39    /// Render the icon as a macOS template (monochrome, tinted by the system).
40    pub template: bool,
41    pub items: Vec<MenuEntry>,
42    pub left_click: LeftClick,
43    pub show_label: String,
44    pub quit_label: String,
45}
46
47impl Default for TraySpec {
48    fn default() -> Self {
49        Self {
50            tooltip: String::new(),
51            icon_png: None,
52            template: false,
53            items: Vec::new(),
54            left_click: if cfg!(target_os = "macos") {
55                LeftClick::ShowMenu
56            } else {
57                LeftClick::ToggleWindow
58            },
59            show_label: "Show Window".into(),
60            quit_label: "Quit".into(),
61        }
62    }
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct TrayUpdate {
68    pub tooltip: Option<String>,
69    /// macOS menu-bar title text next to the icon.
70    pub title: Option<String>,
71    pub icon_png: Option<Vec<u8>>,
72    pub items: Option<Vec<MenuEntry>>,
73}
74
75fn s<E: std::fmt::Display>(e: E) -> String {
76    e.to_string()
77}
78
79fn build_menu(
80    app: &AppHandle<Wry>,
81    spec_items: &[MenuEntry],
82    show: &str,
83    quit: &str,
84) -> Result<(Menu<Wry>, HashSet<String>), String> {
85    let ids: HashSet<String> = spec_items
86        .iter()
87        .filter_map(|e| {
88            if let MenuEntry::Item { id, .. } = e {
89                Some(id.clone())
90            } else {
91                None
92            }
93        })
94        .collect();
95    let menu = Menu::new(app).map_err(s)?;
96    menu.append(&MenuItem::with_id(app, ID_SHOW, show, true, None::<&str>).map_err(s)?)
97        .map_err(s)?;
98    if !spec_items.is_empty() {
99        menu.append(&PredefinedMenuItem::separator(app).map_err(s)?)
100            .map_err(s)?;
101    }
102    for e in spec_items {
103        match e {
104            MenuEntry::Separator => menu
105                .append(&PredefinedMenuItem::separator(app).map_err(s)?)
106                .map_err(s)?,
107            MenuEntry::Item {
108                id,
109                label,
110                accelerator,
111                enabled,
112            } => {
113                menu.append(
114                    &MenuItem::with_id(app, id, label, *enabled, accelerator.as_deref())
115                        .map_err(s)?,
116                )
117                .map_err(s)?;
118            }
119        }
120    }
121    menu.append(&PredefinedMenuItem::separator(app).map_err(s)?)
122        .map_err(s)?;
123    menu.append(&MenuItem::with_id(app, ID_QUIT, quit, true, None::<&str>).map_err(s)?)
124        .map_err(s)?;
125    Ok((menu, ids))
126}
127
128pub struct TrayRuntime {
129    pub icon: Mutex<Option<TrayIcon<Wry>>>,
130    /// Ids of the items currently in the tray menu. Tauri delivers EVERY menu event (including the
131    /// app menu's) to a tray's handler, so only these ids are tray actions.
132    pub ids: Mutex<HashSet<String>>,
133    pub show_label: String,
134    pub quit_label: String,
135}
136
137pub fn install(
138    app: &AppHandle<Wry>,
139    spec: &TraySpec,
140    main_label: &str,
141) -> Result<TrayIcon<Wry>, String> {
142    let (menu, ids) = build_menu(app, &spec.items, &spec.show_label, &spec.quit_label)?;
143    let mut b = TrayIconBuilder::with_id(TRAY_ID)
144        .menu(&menu)
145        .tooltip(&spec.tooltip)
146        .show_menu_on_left_click(spec.left_click == LeftClick::ShowMenu);
147    #[cfg(target_os = "macos")]
148    {
149        b = b.icon_as_template(spec.template);
150    }
151    if let Some(png) = &spec.icon_png {
152        b = b.icon(Image::from_bytes(png).map_err(s)?.to_owned());
153    } else if let Some(icon) = app.default_window_icon() {
154        b = b.icon(icon.clone());
155    }
156    let main = main_label.to_owned();
157    let left = spec.left_click;
158    let tray = b
159        .on_menu_event(|app, event| {
160            let id = event.id.as_ref();
161            match id {
162                ID_SHOW => {
163                    crate::windows::reveal_with(app, None, crate::lifecycle::RevealSource::Tray)
164                }
165                ID_QUIT => crate::lifecycle::request_quit(app, "tray"),
166                other
167                    if app.state::<ShellState>().tray.as_ref().is_some_and(|t| {
168                        t.ids
169                            .lock()
170                            .unwrap_or_else(|e| e.into_inner())
171                            .contains(other)
172                    }) =>
173                {
174                    let _ = app.emit(EVENT_TRAY, serde_json::json!({"kind": "menu", "id": other}));
175                }
176                _ => {}
177            }
178        })
179        .on_tray_icon_event(move |tray, event| {
180            if let TrayIconEvent::Click {
181                button: MouseButton::Left,
182                button_state: MouseButtonState::Up,
183                ..
184            } = event
185            {
186                let app = tray.app_handle();
187                let _ = app.emit(EVENT_TRAY, serde_json::json!({"kind": "click"}));
188                if left == LeftClick::ToggleWindow {
189                    crate::windows::toggle(app, &main);
190                }
191            }
192        })
193        .build(app)
194        .map_err(s)?;
195    if let Some(rt) = &app.state::<ShellState>().tray {
196        *rt.ids.lock().unwrap_or_else(|e| e.into_inner()) = ids;
197    }
198    Ok(tray)
199}
200
201pub fn update(app: &AppHandle<Wry>, u: &TrayUpdate) -> Result<(), String> {
202    let state = app.state::<ShellState>();
203    let rt = state.tray.as_ref().ok_or("no tray installed")?;
204    let guard = rt.icon.lock().unwrap_or_else(|e| e.into_inner());
205    let tray = guard.as_ref().ok_or("tray not ready")?;
206    if let Some(t) = &u.tooltip {
207        tray.set_tooltip(Some(t.as_str())).map_err(s)?;
208    }
209    #[cfg(target_os = "macos")]
210    if let Some(t) = &u.title {
211        tray.set_title(Some(t.as_str())).map_err(s)?;
212    }
213    if let Some(png) = &u.icon_png {
214        tray.set_icon(Some(Image::from_bytes(png).map_err(s)?.to_owned()))
215            .map_err(s)?;
216    }
217    if let Some(items) = &u.items {
218        let (menu, ids) = build_menu(app, items, &rt.show_label, &rt.quit_label)?;
219        tray.set_menu(Some(menu)).map_err(s)?;
220        *rt.ids.lock().unwrap_or_else(|e| e.into_inner()) = ids;
221    }
222    Ok(())
223}