Skip to main content

gity_tray/
lib.rs

1//! Cross-platform system tray for gity daemon.
2//!
3//! Provides a minimal tray icon with Info and Exit actions.
4
5use 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
22/// Menu item IDs
23const INFO_ID: &str = "info";
24const EXIT_ID: &str = "exit";
25
26/// Configuration for the system tray.
27pub struct TrayConfig {
28    pub daemon_address: String,
29}
30
31/// System tray instance.
32pub struct GityTray {
33    _tray: TrayIcon,
34    running: Arc<AtomicBool>,
35}
36
37impl GityTray {
38    /// Create and show the system tray icon.
39    pub fn new(config: TrayConfig) -> Result<Self, TrayError> {
40        let running = Arc::new(AtomicBool::new(true));
41
42        // Create the menu
43        let menu = create_menu()?;
44
45        // Create tray icon with embedded icon data
46        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        // Set up menu event handler
55        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    /// Check if the tray is still running.
79    pub fn is_running(&self) -> bool {
80        self.running.load(Ordering::SeqCst)
81    }
82
83    /// Request shutdown of the tray.
84    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    // Create a simple 32x32 blue square icon
108    let width = 32u32;
109    let height = 32u32;
110    let mut rgba = vec![0u8; (width * height * 4) as usize];
111
112    // Fill with a blue color
113    for i in 0..((width * height) as usize) {
114        rgba[i * 4] = 64; // R
115        rgba[i * 4 + 1] = 128; // G
116        rgba[i * 4 + 2] = 255; // B
117        rgba[i * 4 + 3] = 255; // A
118    }
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    // Create a runtime for the async call
125    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    // Simple console output for now - can be replaced with native notifications
162    println!("{}: {}", title, message);
163
164    // On desktop platforms, we could use a notification library like notify-rust
165    // For now, we just print to console
166}
167
168/// Run the tray event loop. Call this from the main thread.
169#[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}