- Hide console window by default (windows_subsystem = "windows") - Add "Show Debug Window" menu item to tray - AllocConsole when debug window requested - Console shows logs for troubleshooting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
198 lines
5.9 KiB
Rust
198 lines
5.9 KiB
Rust
//! System tray icon and menu for the agent
|
|
//!
|
|
//! Provides a tray icon with menu options:
|
|
//! - Connection status
|
|
//! - Machine name
|
|
//! - End session
|
|
|
|
use anyhow::Result;
|
|
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
|
use tracing::{info, warn};
|
|
|
|
#[cfg(windows)]
|
|
use windows::Win32::UI::WindowsAndMessaging::{
|
|
PeekMessageW, TranslateMessage, DispatchMessageW, MSG, PM_REMOVE,
|
|
};
|
|
|
|
/// Events that can be triggered from the tray menu
|
|
#[derive(Debug, Clone)]
|
|
pub enum TrayAction {
|
|
EndSession,
|
|
ShowDetails,
|
|
ShowDebugWindow,
|
|
}
|
|
|
|
/// Tray icon controller
|
|
pub struct TrayController {
|
|
_tray_icon: TrayIcon,
|
|
menu: Menu,
|
|
end_session_item: MenuItem,
|
|
debug_item: MenuItem,
|
|
status_item: MenuItem,
|
|
exit_requested: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl TrayController {
|
|
/// Create a new tray controller
|
|
/// `allow_end_session` - If true, show "End Session" menu item (only for support sessions)
|
|
pub fn new(machine_name: &str, support_code: Option<&str>, allow_end_session: bool) -> Result<Self> {
|
|
// Create menu items
|
|
let status_text = if let Some(code) = support_code {
|
|
format!("Support Session: {}", code)
|
|
} else {
|
|
"Persistent Agent".to_string()
|
|
};
|
|
|
|
let status_item = MenuItem::new(&status_text, false, None);
|
|
let machine_item = MenuItem::new(format!("Machine: {}", machine_name), false, None);
|
|
let separator = PredefinedMenuItem::separator();
|
|
|
|
// Only show "End Session" for support sessions
|
|
// Persistent agents can only be removed by admin
|
|
let end_session_item = if allow_end_session {
|
|
MenuItem::new("End Session", true, None)
|
|
} else {
|
|
MenuItem::new("Managed by Administrator", false, None)
|
|
};
|
|
|
|
// Debug window option (always available)
|
|
let debug_item = MenuItem::new("Show Debug Window", true, None);
|
|
|
|
// Build menu
|
|
let menu = Menu::new();
|
|
menu.append(&status_item)?;
|
|
menu.append(&machine_item)?;
|
|
menu.append(&separator)?;
|
|
menu.append(&debug_item)?;
|
|
menu.append(&end_session_item)?;
|
|
|
|
// Create tray icon
|
|
let icon = create_default_icon()?;
|
|
|
|
let tray_icon = TrayIconBuilder::new()
|
|
.with_menu(Box::new(menu.clone()))
|
|
.with_tooltip(format!("GuruConnect - {}", machine_name))
|
|
.with_icon(icon)
|
|
.build()?;
|
|
|
|
let exit_requested = Arc::new(AtomicBool::new(false));
|
|
|
|
Ok(Self {
|
|
_tray_icon: tray_icon,
|
|
menu,
|
|
end_session_item,
|
|
debug_item,
|
|
status_item,
|
|
exit_requested,
|
|
})
|
|
}
|
|
|
|
/// Check if exit has been requested
|
|
pub fn exit_requested(&self) -> bool {
|
|
self.exit_requested.load(Ordering::SeqCst)
|
|
}
|
|
|
|
/// Update the connection status display
|
|
pub fn update_status(&self, status: &str) {
|
|
self.status_item.set_text(status);
|
|
}
|
|
|
|
/// Process pending menu events (call this from the main loop)
|
|
pub fn process_events(&self) -> Option<TrayAction> {
|
|
// Pump Windows message queue to process tray icon events
|
|
#[cfg(windows)]
|
|
pump_windows_messages();
|
|
|
|
// Check for menu events
|
|
if let Ok(event) = MenuEvent::receiver().try_recv() {
|
|
if event.id == self.end_session_item.id() {
|
|
info!("End session requested from tray menu");
|
|
self.exit_requested.store(true, Ordering::SeqCst);
|
|
return Some(TrayAction::EndSession);
|
|
}
|
|
if event.id == self.debug_item.id() {
|
|
info!("Debug window requested from tray menu");
|
|
return Some(TrayAction::ShowDebugWindow);
|
|
}
|
|
}
|
|
|
|
// Check for tray icon events (like double-click)
|
|
if let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
|
match event {
|
|
TrayIconEvent::DoubleClick { .. } => {
|
|
info!("Tray icon double-clicked");
|
|
return Some(TrayAction::ShowDetails);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Pump the Windows message queue to process tray icon events
|
|
#[cfg(windows)]
|
|
fn pump_windows_messages() {
|
|
unsafe {
|
|
let mut msg = MSG::default();
|
|
// Process all pending messages
|
|
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
|
let _ = TranslateMessage(&msg);
|
|
DispatchMessageW(&msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a simple default icon (green circle for connected)
|
|
fn create_default_icon() -> Result<Icon> {
|
|
// Create a simple 32x32 green icon
|
|
let size = 32u32;
|
|
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
|
|
|
let center = size as f32 / 2.0;
|
|
let radius = size as f32 / 2.0 - 2.0;
|
|
|
|
for y in 0..size {
|
|
for x in 0..size {
|
|
let dx = x as f32 - center;
|
|
let dy = y as f32 - center;
|
|
let dist = (dx * dx + dy * dy).sqrt();
|
|
|
|
let idx = ((y * size + x) * 4) as usize;
|
|
|
|
if dist <= radius {
|
|
// Green circle
|
|
rgba[idx] = 76; // R
|
|
rgba[idx + 1] = 175; // G
|
|
rgba[idx + 2] = 80; // B
|
|
rgba[idx + 3] = 255; // A
|
|
} else if dist <= radius + 1.0 {
|
|
// Anti-aliased edge
|
|
let alpha = ((radius + 1.0 - dist) * 255.0) as u8;
|
|
rgba[idx] = 76;
|
|
rgba[idx + 1] = 175;
|
|
rgba[idx + 2] = 80;
|
|
rgba[idx + 3] = alpha;
|
|
}
|
|
}
|
|
}
|
|
|
|
let icon = Icon::from_rgba(rgba, size, size)?;
|
|
Ok(icon)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_create_icon() {
|
|
let icon = create_default_icon();
|
|
assert!(icon.is_ok());
|
|
}
|
|
}
|