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:
993
Cargo.lock
generated
993
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
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