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

View File

@@ -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 {