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:
2025-12-28 16:06:16 -07:00
parent 8246d135f9
commit dea96bd300
5 changed files with 1256 additions and 56 deletions

993
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -51,6 +51,13 @@ hostname = "0.4"
# URL encoding # URL encoding
urlencoding = "2" urlencoding = "2"
# System tray (Windows)
tray-icon = "0.19"
muda = "0.15" # Menu for tray icon
# Image handling for tray icon
image = { version = "0.25", default-features = false, features = ["png"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
# Windows APIs for screen capture and input # Windows APIs for screen capture and input
windows = { version = "0.58", features = [ windows = { version = "0.58", features = [

View File

@@ -14,13 +14,14 @@ mod encoder;
mod input; mod input;
mod session; mod session;
mod transport; mod transport;
mod tray;
pub mod proto { pub mod proto {
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs")); include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
} }
use anyhow::Result; use anyhow::Result;
use tracing::{info, error, Level}; use tracing::{info, error, warn, Level};
use tracing_subscriber::FmtSubscriber; use tracing_subscriber::FmtSubscriber;
#[cfg(windows)] #[cfg(windows)]
@@ -122,19 +123,51 @@ async fn run_agent(config: config::Config) -> Result<()> {
// Create session manager // Create session manager
let mut session = session::SessionManager::new(config.clone()); let mut session = session::SessionManager::new(config.clone());
let is_support_session = config.support_code.is_some(); 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 // Connect to server and run main loop
loop { loop {
info!("Connecting to server..."); 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 { match session.connect().await {
Ok(_) => { Ok(_) => {
info!("Connected to server"); info!("Connected to server");
// Run session until disconnect // Update tray status
if let Err(e) = session.run().await { 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(); 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 // Check if this is a cancellation
if error_msg.contains("SESSION_CANCELLED") { if error_msg.contains("SESSION_CANCELLED") {
info!("Session was cancelled by technician"); info!("Session was cancelled by technician");
@@ -173,6 +206,14 @@ async fn run_agent(config: config::Config) -> Result<()> {
return Ok(()); 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) // Wait before reconnecting (only for persistent agent connections)
info!("Reconnecting in 5 seconds..."); info!("Reconnecting in 5 seconds...");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;

View File

@@ -12,6 +12,7 @@ use crate::encoder::{self, Encoder};
use crate::input::InputController; use crate::input::InputController;
use crate::proto::{Message, message}; use crate::proto::{Message, message};
use crate::transport::WebSocketTransport; use crate::transport::WebSocketTransport;
use crate::tray::{TrayController, TrayAction};
use anyhow::Result; use anyhow::Result;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -147,6 +148,113 @@ impl SessionManager {
Ok(()) 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 /// Handle incoming message from server
fn handle_message(&mut self, input: &mut InputController, msg: Message) -> Result<()> { fn handle_message(&mut self, input: &mut InputController, msg: Message) -> Result<()> {
match msg.payload { match msg.payload {

157
agent/src/tray/mod.rs Normal file
View 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());
}
}