Add system tray icon with menu for agent
- Added tray-icon and muda crates for tray functionality - Tray icon shows green circle when connected - Menu displays: session code, machine name, End Session option - End Session menu item cleanly terminates the agent - Tray events processed in session main loop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,13 +14,14 @@ mod encoder;
|
||||
mod input;
|
||||
mod session;
|
||||
mod transport;
|
||||
mod tray;
|
||||
|
||||
pub mod proto {
|
||||
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
|
||||
}
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::{info, error, Level};
|
||||
use tracing::{info, error, warn, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -122,19 +123,51 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
// Create session manager
|
||||
let mut session = session::SessionManager::new(config.clone());
|
||||
let is_support_session = config.support_code.is_some();
|
||||
let hostname = config.hostname();
|
||||
|
||||
// Create tray icon
|
||||
let tray = match tray::TrayController::new(&hostname, config.support_code.as_deref()) {
|
||||
Ok(t) => {
|
||||
info!("Tray icon created");
|
||||
Some(t)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to create tray icon: {}. Continuing without tray.", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to server and run main loop
|
||||
loop {
|
||||
info!("Connecting to server...");
|
||||
|
||||
// Check if user requested exit via tray before connecting
|
||||
if let Some(ref t) = tray {
|
||||
if t.exit_requested() {
|
||||
info!("Exit requested by user");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
match session.connect().await {
|
||||
Ok(_) => {
|
||||
info!("Connected to server");
|
||||
|
||||
// Run session until disconnect
|
||||
if let Err(e) = session.run().await {
|
||||
// Update tray status
|
||||
if let Some(ref t) = tray {
|
||||
t.update_status("Status: Connected");
|
||||
}
|
||||
|
||||
// Run session until disconnect, passing tray for event processing
|
||||
if let Err(e) = session.run_with_tray(tray.as_ref()).await {
|
||||
let error_msg = e.to_string();
|
||||
|
||||
// Check if this is a user-initiated exit
|
||||
if error_msg.contains("USER_EXIT") {
|
||||
info!("Session ended by user");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if this is a cancellation
|
||||
if error_msg.contains("SESSION_CANCELLED") {
|
||||
info!("Session was cancelled by technician");
|
||||
@@ -173,6 +206,14 @@ async fn run_agent(config: config::Config) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if user requested exit via tray
|
||||
if let Some(ref t) = tray {
|
||||
if t.exit_requested() {
|
||||
info!("Exit requested by user");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before reconnecting (only for persistent agent connections)
|
||||
info!("Reconnecting in 5 seconds...");
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::encoder::{self, Encoder};
|
||||
use crate::input::InputController;
|
||||
use crate::proto::{Message, message};
|
||||
use crate::transport::WebSocketTransport;
|
||||
use crate::tray::{TrayController, TrayAction};
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -147,6 +148,113 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the session main loop with tray event processing
|
||||
pub async fn run_with_tray(&mut self, tray: Option<&TrayController>) -> Result<()> {
|
||||
if self.transport.is_none() {
|
||||
anyhow::bail!("Not connected");
|
||||
}
|
||||
|
||||
self.state = SessionState::Active;
|
||||
|
||||
// Get primary display
|
||||
let primary_display = capture::primary_display()?;
|
||||
tracing::info!("Using display: {} ({}x{})", primary_display.name, primary_display.width, primary_display.height);
|
||||
|
||||
// Create capturer
|
||||
let mut capturer = capture::create_capturer(
|
||||
primary_display.clone(),
|
||||
self.config.capture.use_dxgi,
|
||||
self.config.capture.gdi_fallback,
|
||||
)?;
|
||||
|
||||
// Create encoder
|
||||
let mut encoder = encoder::create_encoder(
|
||||
&self.config.encoding.codec,
|
||||
self.config.encoding.quality,
|
||||
)?;
|
||||
|
||||
// Create input controller
|
||||
let mut input = InputController::new()?;
|
||||
|
||||
// Calculate frame interval
|
||||
let frame_interval = Duration::from_millis(1000 / self.config.capture.fps as u64);
|
||||
let mut last_frame_time = Instant::now();
|
||||
|
||||
// Main loop
|
||||
loop {
|
||||
// Process tray events
|
||||
if let Some(t) = tray {
|
||||
if let Some(action) = t.process_events() {
|
||||
match action {
|
||||
TrayAction::EndSession => {
|
||||
tracing::info!("User requested session end via tray");
|
||||
return Err(anyhow::anyhow!("USER_EXIT: Session ended by user"));
|
||||
}
|
||||
TrayAction::ShowDetails => {
|
||||
// TODO: Show a details dialog
|
||||
tracing::info!("User requested details (not yet implemented)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if exit was requested
|
||||
if t.exit_requested() {
|
||||
tracing::info!("Exit requested via tray");
|
||||
return Err(anyhow::anyhow!("USER_EXIT: Exit requested by user"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for incoming messages (non-blocking)
|
||||
let messages: Vec<Message> = {
|
||||
let transport = self.transport.as_mut().unwrap();
|
||||
let mut msgs = Vec::new();
|
||||
while let Some(msg) = transport.try_recv()? {
|
||||
msgs.push(msg);
|
||||
}
|
||||
msgs
|
||||
};
|
||||
|
||||
for msg in messages {
|
||||
self.handle_message(&mut input, msg)?;
|
||||
}
|
||||
|
||||
// Capture and send frame if interval elapsed
|
||||
if last_frame_time.elapsed() >= frame_interval {
|
||||
last_frame_time = Instant::now();
|
||||
|
||||
if let Some(frame) = capturer.capture()? {
|
||||
let encoded = encoder.encode(&frame)?;
|
||||
|
||||
// Skip empty frames (no changes)
|
||||
if encoded.size > 0 {
|
||||
let msg = Message {
|
||||
payload: Some(message::Payload::VideoFrame(encoded.frame)),
|
||||
};
|
||||
let transport = self.transport.as_mut().unwrap();
|
||||
transport.send(msg).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small sleep to prevent busy loop
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
|
||||
// Check if still connected
|
||||
if let Some(transport) = self.transport.as_ref() {
|
||||
if !transport.is_connected() {
|
||||
tracing::warn!("Connection lost");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Transport is None");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
self.state = SessionState::Disconnected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle incoming message from server
|
||||
fn handle_message(&mut self, input: &mut InputController, msg: Message) -> Result<()> {
|
||||
match msg.payload {
|
||||
|
||||
157
agent/src/tray/mod.rs
Normal file
157
agent/src/tray/mod.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! 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};
|
||||
|
||||
/// Events that can be triggered from the tray menu
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrayAction {
|
||||
EndSession,
|
||||
ShowDetails,
|
||||
// Future: OpenChat,
|
||||
}
|
||||
|
||||
/// Tray icon controller
|
||||
pub struct TrayController {
|
||||
_tray_icon: TrayIcon,
|
||||
menu: Menu,
|
||||
end_session_item: MenuItem,
|
||||
status_item: MenuItem,
|
||||
exit_requested: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TrayController {
|
||||
/// Create a new tray controller
|
||||
pub fn new(machine_name: &str, support_code: Option<&str>) -> Result<Self> {
|
||||
// Create menu items
|
||||
let status_text = if let Some(code) = support_code {
|
||||
format!("Support Session: {}", code)
|
||||
} else {
|
||||
"Connected".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();
|
||||
let end_session_item = MenuItem::new("End Session", true, None);
|
||||
|
||||
// Build menu
|
||||
let menu = Menu::new();
|
||||
menu.append(&status_item)?;
|
||||
menu.append(&machine_item)?;
|
||||
menu.append(&separator)?;
|
||||
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,
|
||||
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> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user