1use 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 ToggleWindow,
30 ShowMenu,
32}
33
34#[derive(Debug, Clone)]
35pub struct TraySpec {
36 pub tooltip: String,
37 pub icon_png: Option<Vec<u8>>,
39 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 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 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}