1use gity_ipc::{DaemonCommand, DaemonResponse, DaemonService};
6use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use thiserror::Error;
10use tray_icon::{TrayIcon, TrayIconBuilder};
11
12#[derive(Debug, Error)]
13pub enum TrayError {
14 #[error("failed to create tray: {0}")]
15 Creation(String),
16 #[error("failed to create menu: {0}")]
17 Menu(String),
18 #[error("platform error: {0}")]
19 Platform(String),
20}
21
22const INFO_ID: &str = "info";
24const EXIT_ID: &str = "exit";
25
26pub struct TrayConfig {
28 pub daemon_address: String,
29}
30
31pub struct GityTray {
33 _tray: TrayIcon,
34 running: Arc<AtomicBool>,
35}
36
37impl GityTray {
38 pub fn new(config: TrayConfig) -> Result<Self, TrayError> {
40 let running = Arc::new(AtomicBool::new(true));
41
42 let menu = create_menu()?;
44
45 let icon = load_icon()?;
47 let tray = TrayIconBuilder::new()
48 .with_menu(Box::new(menu))
49 .with_tooltip("Gity - Git Helper Daemon")
50 .with_icon(icon)
51 .build()
52 .map_err(|e| TrayError::Creation(e.to_string()))?;
53
54 let running_clone = Arc::clone(&running);
56 let daemon_address = config.daemon_address;
57 std::thread::spawn(move || loop {
58 if let Ok(event) = MenuEvent::receiver().recv() {
59 match event.id.0.as_str() {
60 INFO_ID => {
61 handle_info_action(&daemon_address);
62 }
63 EXIT_ID => {
64 running_clone.store(false, Ordering::SeqCst);
65 break;
66 }
67 _ => {}
68 }
69 }
70 });
71
72 Ok(Self {
73 _tray: tray,
74 running,
75 })
76 }
77
78 pub fn is_running(&self) -> bool {
80 self.running.load(Ordering::SeqCst)
81 }
82
83 pub fn shutdown(&self) {
85 self.running.store(false, Ordering::SeqCst);
86 }
87}
88
89fn create_menu() -> Result<Menu, TrayError> {
90 let menu = Menu::new();
91
92 let info_item = MenuItem::with_id(INFO_ID, "Info", true, None);
93 let separator = PredefinedMenuItem::separator();
94 let exit_item = MenuItem::with_id(EXIT_ID, "Exit", true, None);
95
96 menu.append(&info_item)
97 .map_err(|e| TrayError::Menu(e.to_string()))?;
98 menu.append(&separator)
99 .map_err(|e| TrayError::Menu(e.to_string()))?;
100 menu.append(&exit_item)
101 .map_err(|e| TrayError::Menu(e.to_string()))?;
102
103 Ok(menu)
104}
105
106fn load_icon() -> Result<tray_icon::Icon, TrayError> {
107 let width = 32u32;
109 let height = 32u32;
110 let mut rgba = vec![0u8; (width * height * 4) as usize];
111
112 for i in 0..((width * height) as usize) {
114 rgba[i * 4] = 64; rgba[i * 4 + 1] = 128; rgba[i * 4 + 2] = 255; rgba[i * 4 + 3] = 255; }
119
120 tray_icon::Icon::from_rgba(rgba, width, height).map_err(|e| TrayError::Creation(e.to_string()))
121}
122
123fn handle_info_action(daemon_address: &str) {
124 let rt = match tokio::runtime::Builder::new_current_thread()
126 .enable_all()
127 .build()
128 {
129 Ok(rt) => rt,
130 Err(e) => {
131 eprintln!("failed to create runtime: {e}");
132 return;
133 }
134 };
135
136 rt.block_on(async {
137 let client = gity_daemon::NngClient::new(daemon_address.to_string());
138 match client.execute(DaemonCommand::HealthCheck).await {
139 Ok(DaemonResponse::Health(health)) => {
140 let info = format!(
141 "Gity Daemon Status\n\
142 ------------------\n\
143 Repositories: {}\n\
144 Pending Jobs: {}\n\
145 Uptime: {}s",
146 health.repo_count, health.pending_jobs, health.uptime_seconds
147 );
148 show_notification("Gity Info", &info);
149 }
150 Ok(response) => {
151 show_notification("Gity Info", &format!("Unexpected response: {:?}", response));
152 }
153 Err(e) => {
154 show_notification("Gity Error", &format!("Daemon not running: {}", e));
155 }
156 }
157 });
158}
159
160fn show_notification(title: &str, message: &str) {
161 println!("{}: {}", title, message);
163
164 }
167
168#[cfg(target_os = "linux")]
170pub fn run_tray_loop(tray: &GityTray) {
171 use gtk::glib;
172
173 if gtk::init().is_err() {
174 eprintln!("Failed to initialize GTK");
175 return;
176 }
177
178 let running = Arc::clone(&tray.running);
179
180 glib::idle_add_local(move || {
181 if running.load(Ordering::SeqCst) {
182 glib::ControlFlow::Continue
183 } else {
184 gtk::main_quit();
185 glib::ControlFlow::Break
186 }
187 });
188
189 gtk::main();
190}
191
192#[cfg(any(target_os = "windows", target_os = "macos"))]
193#[allow(deprecated)]
194pub fn run_tray_loop(tray: &GityTray) {
195 use winit::event_loop::{ControlFlow, EventLoop};
196
197 let event_loop = match EventLoop::new() {
198 Ok(el) => el,
199 Err(e) => {
200 eprintln!("Failed to create event loop: {e}");
201 return;
202 }
203 };
204
205 let running = Arc::clone(&tray.running);
206
207 let _ = event_loop.run(move |_event, elwt| {
208 elwt.set_control_flow(ControlFlow::Wait);
209 if !running.load(Ordering::SeqCst) {
210 elwt.exit();
211 }
212 });
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn icon_loads_successfully() {
221 let icon = load_icon();
222 assert!(icon.is_ok());
223 }
224
225 #[test]
226 #[cfg_attr(target_os = "macos", ignore = "muda requires main thread on macOS")]
227 fn menu_creates_successfully() {
228 let menu = create_menu();
229 assert!(menu.is_ok());
230 }
231}