Add VPN configuration tools and agent documentation

Created comprehensive VPN setup tooling for Peaceful Spirit L2TP/IPsec connection
and enhanced agent documentation framework.

VPN Configuration (PST-NW-VPN):
- Setup-PST-L2TP-VPN.ps1: Automated L2TP/IPsec setup with split-tunnel and DNS
- Connect-PST-VPN.ps1: Connection helper with PPP adapter detection, DNS (192.168.0.2), and route config (192.168.0.0/24)
- Connect-PST-VPN-Standalone.ps1: Self-contained connection script for remote deployment
- Fix-PST-VPN-Auth.ps1: Authentication troubleshooting for CHAP/MSChapv2
- Diagnose-VPN-Interface.ps1: Comprehensive VPN interface and routing diagnostic
- Quick-Test-VPN.ps1: Fast connectivity verification (DNS/router/routes)
- Add-PST-VPN-Route-Manual.ps1: Manual route configuration helper
- vpn-connect.bat, vpn-disconnect.bat: Simple batch file shortcuts
- OpenVPN config files (Windows-compatible, abandoned for L2TP)

Key VPN Implementation Details:
- L2TP creates PPP adapter with connection name as interface description
- UniFi auto-configures DNS (192.168.0.2) but requires manual route to 192.168.0.0/24
- Split-tunnel enabled (only remote traffic through VPN)
- All-user connection for pre-login auto-connect via scheduled task
- Authentication: CHAP + MSChapv2 for UniFi compatibility

Agent Documentation:
- AGENT_QUICK_REFERENCE.md: Quick reference for all specialized agents
- documentation-squire.md: Documentation and task management specialist agent
- Updated all agent markdown files with standardized formatting

Project Organization:
- Moved conversation logs to dedicated directories (guru-connect-conversation-logs, guru-rmm-conversation-logs)
- Cleaned up old session JSONL files from projects/msp-tools/
- Added guru-connect infrastructure (agent, dashboard, proto, scripts, .gitea workflows)
- Added guru-rmm server components and deployment configs

Technical Notes:
- VPN IP pool: 192.168.4.x (client gets 192.168.4.6)
- Remote network: 192.168.0.0/24 (router at 192.168.0.10)
- PSK: rrClvnmUeXEFo90Ol+z7tfsAZHeSK6w7
- Credentials: pst-admin / 24Hearts$

Files: 15 VPN scripts, 2 agent docs, conversation log reorganization,
guru-connect/guru-rmm infrastructure additions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 11:51:47 -07:00
parent b0a68d89bf
commit 6c316aa701
272 changed files with 37068 additions and 2 deletions

View File

@@ -0,0 +1,638 @@
//! GuruConnect SAS Service
//!
//! Windows Service running as SYSTEM to handle Ctrl+Alt+Del (Secure Attention Sequence).
//! The agent communicates with this service via named pipe IPC.
use std::ffi::OsString;
use std::io::{Read, Write as IoWrite};
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Context, Result};
use windows::core::{s, w};
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
use windows_service::{
define_windows_service,
service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
service_manager::{ServiceManager, ServiceManagerAccess},
};
// Service configuration
const SERVICE_NAME: &str = "GuruConnectSAS";
const SERVICE_DISPLAY_NAME: &str = "GuruConnect SAS Service";
const SERVICE_DESCRIPTION: &str = "Handles Secure Attention Sequence (Ctrl+Alt+Del) for GuruConnect remote sessions";
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
const INSTALL_DIR: &str = r"C:\Program Files\GuruConnect";
// Windows named pipe constants
const PIPE_ACCESS_DUPLEX: u32 = 0x00000003;
const PIPE_TYPE_MESSAGE: u32 = 0x00000004;
const PIPE_READMODE_MESSAGE: u32 = 0x00000002;
const PIPE_WAIT: u32 = 0x00000000;
const PIPE_UNLIMITED_INSTANCES: u32 = 255;
const INVALID_HANDLE_VALUE: isize = -1;
const SECURITY_DESCRIPTOR_REVISION: u32 = 1;
// FFI declarations for named pipe operations
#[link(name = "kernel32")]
extern "system" {
fn CreateNamedPipeW(
lpName: *const u16,
dwOpenMode: u32,
dwPipeMode: u32,
nMaxInstances: u32,
nOutBufferSize: u32,
nInBufferSize: u32,
nDefaultTimeOut: u32,
lpSecurityAttributes: *mut SECURITY_ATTRIBUTES,
) -> isize;
fn ConnectNamedPipe(hNamedPipe: isize, lpOverlapped: *mut std::ffi::c_void) -> i32;
fn DisconnectNamedPipe(hNamedPipe: isize) -> i32;
fn CloseHandle(hObject: isize) -> i32;
fn ReadFile(
hFile: isize,
lpBuffer: *mut u8,
nNumberOfBytesToRead: u32,
lpNumberOfBytesRead: *mut u32,
lpOverlapped: *mut std::ffi::c_void,
) -> i32;
fn WriteFile(
hFile: isize,
lpBuffer: *const u8,
nNumberOfBytesToWrite: u32,
lpNumberOfBytesWritten: *mut u32,
lpOverlapped: *mut std::ffi::c_void,
) -> i32;
fn FlushFileBuffers(hFile: isize) -> i32;
}
#[link(name = "advapi32")]
extern "system" {
fn InitializeSecurityDescriptor(pSecurityDescriptor: *mut u8, dwRevision: u32) -> i32;
fn SetSecurityDescriptorDacl(
pSecurityDescriptor: *mut u8,
bDaclPresent: i32,
pDacl: *mut std::ffi::c_void,
bDaclDefaulted: i32,
) -> i32;
}
#[repr(C)]
struct SECURITY_ATTRIBUTES {
nLength: u32,
lpSecurityDescriptor: *mut u8,
bInheritHandle: i32,
}
fn main() {
// Set up logging
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_target(false)
.init();
match std::env::args().nth(1).as_deref() {
Some("install") => {
if let Err(e) = install_service() {
eprintln!("Failed to install service: {}", e);
std::process::exit(1);
}
}
Some("uninstall") => {
if let Err(e) = uninstall_service() {
eprintln!("Failed to uninstall service: {}", e);
std::process::exit(1);
}
}
Some("start") => {
if let Err(e) = start_service() {
eprintln!("Failed to start service: {}", e);
std::process::exit(1);
}
}
Some("stop") => {
if let Err(e) = stop_service() {
eprintln!("Failed to stop service: {}", e);
std::process::exit(1);
}
}
Some("status") => {
if let Err(e) = query_status() {
eprintln!("Failed to query status: {}", e);
std::process::exit(1);
}
}
Some("service") => {
// Called by SCM when service starts
if let Err(e) = run_as_service() {
eprintln!("Service error: {}", e);
std::process::exit(1);
}
}
Some("test") => {
// Test mode: run pipe server directly (for debugging)
println!("Running in test mode (not as service)...");
if let Err(e) = run_pipe_server() {
eprintln!("Pipe server error: {}", e);
std::process::exit(1);
}
}
_ => {
print_usage();
}
}
}
fn print_usage() {
println!("GuruConnect SAS Service");
println!();
println!("Usage: guruconnect-sas-service <command>");
println!();
println!("Commands:");
println!(" install Install the service");
println!(" uninstall Remove the service");
println!(" start Start the service");
println!(" stop Stop the service");
println!(" status Query service status");
println!(" test Run in test mode (not as service)");
}
// Generate the Windows service boilerplate
define_windows_service!(ffi_service_main, service_main);
/// Entry point called by the Windows Service Control Manager
fn run_as_service() -> Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.context("Failed to start service dispatcher")?;
Ok(())
}
/// Main service function called by the SCM
fn service_main(_arguments: Vec<OsString>) {
if let Err(e) = run_service() {
tracing::error!("Service error: {}", e);
}
}
/// The actual service implementation
fn run_service() -> Result<()> {
// Create a channel to receive stop events
let (shutdown_tx, shutdown_rx) = mpsc::channel();
// Create the service control handler
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
ServiceControl::Stop | ServiceControl::Shutdown => {
tracing::info!("Received stop/shutdown command");
let _ = shutdown_tx.send(());
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
};
// Register the service control handler
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)
.context("Failed to register service control handler")?;
// Report that we're starting
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StartPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(5),
process_id: None,
})
.ok();
// Report that we're running
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.ok();
tracing::info!("GuruConnect SAS Service started");
// Run the pipe server in a separate thread
let pipe_handle = std::thread::spawn(|| {
if let Err(e) = run_pipe_server() {
tracing::error!("Pipe server error: {}", e);
}
});
// Wait for shutdown signal
let _ = shutdown_rx.recv();
tracing::info!("Shutting down...");
// Report that we're stopping
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StopPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(3),
process_id: None,
})
.ok();
// The pipe thread will exit when the service stops
drop(pipe_handle);
// Report stopped
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})
.ok();
Ok(())
}
/// Run the named pipe server
fn run_pipe_server() -> Result<()> {
tracing::info!("Starting pipe server on {}", PIPE_NAME);
loop {
// Create security descriptor that allows everyone
let mut sd = [0u8; 256];
unsafe {
if InitializeSecurityDescriptor(sd.as_mut_ptr(), SECURITY_DESCRIPTOR_REVISION) == 0 {
tracing::error!("Failed to initialize security descriptor");
std::thread::sleep(Duration::from_secs(1));
continue;
}
// Set NULL DACL = allow everyone
if SetSecurityDescriptorDacl(sd.as_mut_ptr(), 1, std::ptr::null_mut(), 0) == 0 {
tracing::error!("Failed to set security descriptor DACL");
std::thread::sleep(Duration::from_secs(1));
continue;
}
}
let mut sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd.as_mut_ptr(),
bInheritHandle: 0,
};
// Create the pipe name as wide string
let pipe_name: Vec<u16> = PIPE_NAME.encode_utf16().chain(std::iter::once(0)).collect();
// Create the named pipe
let pipe = unsafe {
CreateNamedPipeW(
pipe_name.as_ptr(),
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
512,
512,
0,
&mut sa,
)
};
if pipe == INVALID_HANDLE_VALUE {
tracing::error!("Failed to create named pipe");
std::thread::sleep(Duration::from_secs(1));
continue;
}
tracing::info!("Waiting for client connection...");
// Wait for a client to connect
let connected = unsafe { ConnectNamedPipe(pipe, std::ptr::null_mut()) };
if connected == 0 {
let err = std::io::Error::last_os_error();
// ERROR_PIPE_CONNECTED (535) means client connected between Create and Connect
if err.raw_os_error() != Some(535) {
tracing::warn!("ConnectNamedPipe error: {}", err);
}
}
tracing::info!("Client connected");
// Read command from pipe
let mut buffer = [0u8; 512];
let mut bytes_read = 0u32;
let read_result = unsafe {
ReadFile(
pipe,
buffer.as_mut_ptr(),
buffer.len() as u32,
&mut bytes_read,
std::ptr::null_mut(),
)
};
if read_result != 0 && bytes_read > 0 {
let command = String::from_utf8_lossy(&buffer[..bytes_read as usize]);
let command = command.trim();
tracing::info!("Received command: {}", command);
let response = match command {
"sas" => {
match send_sas() {
Ok(()) => {
tracing::info!("SendSAS executed successfully");
"ok\n"
}
Err(e) => {
tracing::error!("SendSAS failed: {}", e);
"error\n"
}
}
}
"ping" => {
tracing::info!("Ping received");
"pong\n"
}
_ => {
tracing::warn!("Unknown command: {}", command);
"unknown\n"
}
};
// Write response
let mut bytes_written = 0u32;
unsafe {
WriteFile(
pipe,
response.as_ptr(),
response.len() as u32,
&mut bytes_written,
std::ptr::null_mut(),
);
FlushFileBuffers(pipe);
}
}
// Disconnect and close the pipe
unsafe {
DisconnectNamedPipe(pipe);
CloseHandle(pipe);
}
}
}
/// Call SendSAS via sas.dll
fn send_sas() -> Result<()> {
unsafe {
let lib = LoadLibraryW(w!("sas.dll")).context("Failed to load sas.dll")?;
let proc = GetProcAddress(lib, s!("SendSAS"));
if proc.is_none() {
anyhow::bail!("SendSAS function not found in sas.dll");
}
// SendSAS takes a BOOL parameter: FALSE (0) = Ctrl+Alt+Del
type SendSASFn = unsafe extern "system" fn(i32);
let send_sas_fn: SendSASFn = std::mem::transmute(proc.unwrap());
tracing::info!("Calling SendSAS(0)...");
send_sas_fn(0);
Ok(())
}
}
/// Install the service
fn install_service() -> Result<()> {
println!("Installing GuruConnect SAS Service...");
// Get current executable path
let current_exe = std::env::current_exe().context("Failed to get current executable")?;
let binary_dest = std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
// Create install directory
std::fs::create_dir_all(INSTALL_DIR).context("Failed to create install directory")?;
// Copy binary
println!("Copying binary to: {:?}", binary_dest);
std::fs::copy(&current_exe, &binary_dest).context("Failed to copy binary")?;
// Open service manager
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
// Check if service exists and remove it
if let Ok(service) = manager.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::DELETE | ServiceAccess::STOP,
) {
println!("Removing existing service...");
if let Ok(status) = service.query_status() {
if status.current_state != ServiceState::Stopped {
let _ = service.stop();
std::thread::sleep(Duration::from_secs(2));
}
}
service.delete().context("Failed to delete existing service")?;
drop(service);
std::thread::sleep(Duration::from_secs(2));
}
// Create the service
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: binary_dest.clone(),
launch_arguments: vec![OsString::from("service")],
dependencies: vec![],
account_name: None, // LocalSystem
account_password: None,
};
let service = manager
.create_service(&service_info, ServiceAccess::CHANGE_CONFIG | ServiceAccess::START)
.context("Failed to create service")?;
// Set description
service
.set_description(SERVICE_DESCRIPTION)
.context("Failed to set service description")?;
// Configure recovery
let _ = std::process::Command::new("sc")
.args([
"failure",
SERVICE_NAME,
"reset=86400",
"actions=restart/5000/restart/5000/restart/5000",
])
.output();
println!("\n** GuruConnect SAS Service installed successfully!");
println!("\nBinary: {:?}", binary_dest);
println!("\nStarting service...");
// Start the service
start_service()?;
Ok(())
}
/// Uninstall the service
fn uninstall_service() -> Result<()> {
println!("Uninstalling GuruConnect SAS Service...");
let binary_path = std::path::PathBuf::from(format!(r"{}\\guruconnect-sas-service.exe", INSTALL_DIR));
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager. Run as Administrator.")?;
match manager.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
) {
Ok(service) => {
if let Ok(status) = service.query_status() {
if status.current_state != ServiceState::Stopped {
println!("Stopping service...");
let _ = service.stop();
std::thread::sleep(Duration::from_secs(3));
}
}
println!("Deleting service...");
service.delete().context("Failed to delete service")?;
}
Err(_) => {
println!("Service was not installed");
}
}
// Remove binary
if binary_path.exists() {
std::thread::sleep(Duration::from_secs(1));
if let Err(e) = std::fs::remove_file(&binary_path) {
println!("Warning: Failed to remove binary: {}", e);
}
}
println!("\n** GuruConnect SAS Service uninstalled successfully!");
Ok(())
}
/// Start the service
fn start_service() -> Result<()> {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager")?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::START | ServiceAccess::QUERY_STATUS)
.context("Failed to open service. Is it installed?")?;
service.start::<String>(&[]).context("Failed to start service")?;
std::thread::sleep(Duration::from_secs(1));
let status = service.query_status()?;
match status.current_state {
ServiceState::Running => println!("** Service started successfully"),
ServiceState::StartPending => println!("** Service is starting..."),
other => println!("Service state: {:?}", other),
}
Ok(())
}
/// Stop the service
fn stop_service() -> Result<()> {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager")?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::STOP | ServiceAccess::QUERY_STATUS)
.context("Failed to open service")?;
service.stop().context("Failed to stop service")?;
std::thread::sleep(Duration::from_secs(1));
let status = service.query_status()?;
match status.current_state {
ServiceState::Stopped => println!("** Service stopped"),
ServiceState::StopPending => println!("** Service is stopping..."),
other => println!("Service state: {:?}", other),
}
Ok(())
}
/// Query service status
fn query_status() -> Result<()> {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("Failed to connect to Service Control Manager")?;
match manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) {
Ok(service) => {
let status = service.query_status()?;
println!("GuruConnect SAS Service");
println!("=======================");
println!("Name: {}", SERVICE_NAME);
println!("State: {:?}", status.current_state);
println!("Binary: {}\\guruconnect-sas-service.exe", INSTALL_DIR);
println!("Pipe: {}", PIPE_NAME);
}
Err(_) => {
println!("GuruConnect SAS Service");
println!("=======================");
println!("Status: NOT INSTALLED");
println!("\nTo install: guruconnect-sas-service install");
}
}
Ok(())
}

View File

@@ -0,0 +1,159 @@
//! Display enumeration and information
use anyhow::Result;
/// Information about a display/monitor
#[derive(Debug, Clone)]
pub struct Display {
/// Unique display ID
pub id: u32,
/// Display name (e.g., "\\\\.\\DISPLAY1")
pub name: String,
/// X position in virtual screen coordinates
pub x: i32,
/// Y position in virtual screen coordinates
pub y: i32,
/// Width in pixels
pub width: u32,
/// Height in pixels
pub height: u32,
/// Whether this is the primary display
pub is_primary: bool,
/// Platform-specific handle (HMONITOR on Windows)
#[cfg(windows)]
pub handle: isize,
}
/// Display info for protocol messages
#[derive(Debug, Clone)]
pub struct DisplayInfo {
pub displays: Vec<Display>,
pub primary_id: u32,
}
impl Display {
/// Total pixels in the display
pub fn pixel_count(&self) -> u32 {
self.width * self.height
}
/// Bytes needed for BGRA frame buffer
pub fn buffer_size(&self) -> usize {
(self.width * self.height * 4) as usize
}
}
/// Enumerate all connected displays
#[cfg(windows)]
pub fn enumerate_displays() -> Result<Vec<Display>> {
use windows::Win32::Graphics::Gdi::{
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITORINFOEXW,
};
use windows::Win32::Foundation::{BOOL, LPARAM, RECT};
use std::mem;
let mut displays = Vec::new();
let mut display_id = 0u32;
// Callback for EnumDisplayMonitors
unsafe extern "system" fn enum_callback(
hmonitor: HMONITOR,
_hdc: windows::Win32::Graphics::Gdi::HDC,
_rect: *mut RECT,
lparam: LPARAM,
) -> BOOL {
let displays = &mut *(lparam.0 as *mut Vec<(HMONITOR, u32)>);
let id = displays.len() as u32;
displays.push((hmonitor, id));
BOOL(1) // Continue enumeration
}
// Collect all monitor handles
let mut monitors: Vec<(windows::Win32::Graphics::Gdi::HMONITOR, u32)> = Vec::new();
unsafe {
let result = EnumDisplayMonitors(
None,
None,
Some(enum_callback),
LPARAM(&mut monitors as *mut _ as isize),
);
if !result.as_bool() {
anyhow::bail!("EnumDisplayMonitors failed");
}
}
// Get detailed info for each monitor
for (hmonitor, id) in monitors {
let mut info: MONITORINFOEXW = unsafe { mem::zeroed() };
info.monitorInfo.cbSize = mem::size_of::<MONITORINFOEXW>() as u32;
unsafe {
if GetMonitorInfoW(hmonitor, &mut info.monitorInfo as *mut _ as *mut _).as_bool() {
let rect = info.monitorInfo.rcMonitor;
let name = String::from_utf16_lossy(
&info.szDevice[..info.szDevice.iter().position(|&c| c == 0).unwrap_or(info.szDevice.len())]
);
let is_primary = (info.monitorInfo.dwFlags & 1) != 0; // MONITORINFOF_PRIMARY
displays.push(Display {
id,
name,
x: rect.left,
y: rect.top,
width: (rect.right - rect.left) as u32,
height: (rect.bottom - rect.top) as u32,
is_primary,
handle: hmonitor.0 as isize,
});
}
}
}
// Sort by position (left to right, top to bottom)
displays.sort_by(|a, b| {
if a.y != b.y {
a.y.cmp(&b.y)
} else {
a.x.cmp(&b.x)
}
});
// Reassign IDs after sorting
for (i, display) in displays.iter_mut().enumerate() {
display.id = i as u32;
}
if displays.is_empty() {
anyhow::bail!("No displays found");
}
Ok(displays)
}
#[cfg(not(windows))]
pub fn enumerate_displays() -> Result<Vec<Display>> {
anyhow::bail!("Display enumeration only supported on Windows")
}
/// Get display info for protocol
pub fn get_display_info() -> Result<DisplayInfo> {
let displays = enumerate_displays()?;
let primary_id = displays
.iter()
.find(|d| d.is_primary)
.map(|d| d.id)
.unwrap_or(0);
Ok(DisplayInfo {
displays,
primary_id,
})
}

View File

@@ -0,0 +1,326 @@
//! DXGI Desktop Duplication screen capture
//!
//! Uses the Windows Desktop Duplication API (available on Windows 8+) for
//! high-performance, low-latency screen capture with hardware acceleration.
//!
//! Reference: RustDesk's scrap library implementation
use super::{CapturedFrame, Capturer, DirtyRect, Display};
use anyhow::{Context, Result};
use std::ptr;
use std::time::Instant;
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
D3D11_USAGE_STAGING, D3D11_MAPPED_SUBRESOURCE, D3D11_MAP_READ,
};
use windows::Win32::Graphics::Dxgi::{
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIOutput, IDXGIOutput1,
IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_ACCESS_LOST,
DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO,
DXGI_RESOURCE_PRIORITY_MAXIMUM,
};
use windows::core::Interface;
/// DXGI Desktop Duplication capturer
pub struct DxgiCapturer {
display: Display,
device: ID3D11Device,
context: ID3D11DeviceContext,
duplication: IDXGIOutputDuplication,
staging_texture: Option<ID3D11Texture2D>,
width: u32,
height: u32,
last_frame: Option<Vec<u8>>,
}
impl DxgiCapturer {
/// Create a new DXGI capturer for the specified display
pub fn new(display: Display) -> Result<Self> {
let (device, context, duplication, desc) = Self::create_duplication(&display)?;
Ok(Self {
display,
device,
context,
duplication,
staging_texture: None,
width: desc.ModeDesc.Width,
height: desc.ModeDesc.Height,
last_frame: None,
})
}
/// Create D3D device and output duplication
fn create_duplication(
target_display: &Display,
) -> Result<(ID3D11Device, ID3D11DeviceContext, IDXGIOutputDuplication, DXGI_OUTDUPL_DESC)> {
unsafe {
// Create DXGI factory
let factory: IDXGIFactory1 = CreateDXGIFactory1()
.context("Failed to create DXGI factory")?;
// Find the adapter and output for this display
let (adapter, output) = Self::find_adapter_output(&factory, target_display)?;
// Create D3D11 device
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
None,
Default::default(),
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.context("Failed to create D3D11 device")?;
let device = device.context("D3D11 device is None")?;
let context = context.context("D3D11 context is None")?;
// Get IDXGIOutput1 interface
let output1: IDXGIOutput1 = output.cast()
.context("Failed to get IDXGIOutput1 interface")?;
// Create output duplication
let duplication = output1.DuplicateOutput(&device)
.context("Failed to create output duplication")?;
// Get duplication description
let desc = duplication.GetDesc();
tracing::info!(
"Created DXGI duplication: {}x{}, display: {}",
desc.ModeDesc.Width,
desc.ModeDesc.Height,
target_display.name
);
Ok((device, context, duplication, desc))
}
}
/// Find the adapter and output for the specified display
fn find_adapter_output(
factory: &IDXGIFactory1,
display: &Display,
) -> Result<(IDXGIAdapter1, IDXGIOutput)> {
unsafe {
let mut adapter_idx = 0u32;
loop {
// Enumerate adapters
let adapter: IDXGIAdapter1 = match factory.EnumAdapters1(adapter_idx) {
Ok(a) => a,
Err(_) => break,
};
let mut output_idx = 0u32;
loop {
// Enumerate outputs for this adapter
let output: IDXGIOutput = match adapter.EnumOutputs(output_idx) {
Ok(o) => o,
Err(_) => break,
};
// Check if this is the display we want
let desc = output.GetDesc()?;
let name = String::from_utf16_lossy(
&desc.DeviceName[..desc.DeviceName.iter().position(|&c| c == 0).unwrap_or(desc.DeviceName.len())]
);
if name == display.name || desc.Monitor.0 as isize == display.handle {
return Ok((adapter, output));
}
output_idx += 1;
}
adapter_idx += 1;
}
// If we didn't find the specific display, use the first one
let adapter: IDXGIAdapter1 = factory.EnumAdapters1(0)
.context("No adapters found")?;
let output: IDXGIOutput = adapter.EnumOutputs(0)
.context("No outputs found")?;
Ok((adapter, output))
}
}
/// Create or get the staging texture for CPU access
fn get_staging_texture(&mut self, src_texture: &ID3D11Texture2D) -> Result<&ID3D11Texture2D> {
if self.staging_texture.is_none() {
unsafe {
let mut desc = D3D11_TEXTURE2D_DESC::default();
src_texture.GetDesc(&mut desc);
desc.Usage = D3D11_USAGE_STAGING;
desc.BindFlags = Default::default();
desc.CPUAccessFlags = 0x20000; // D3D11_CPU_ACCESS_READ
desc.MiscFlags = Default::default();
let mut staging: Option<ID3D11Texture2D> = None;
self.device.CreateTexture2D(&desc, None, Some(&mut staging))
.context("Failed to create staging texture")?;
let staging = staging.context("Staging texture is None")?;
// Set high priority
let resource: IDXGIResource = staging.cast()?;
resource.SetEvictionPriority(DXGI_RESOURCE_PRIORITY_MAXIMUM)?;
self.staging_texture = Some(staging);
}
}
Ok(self.staging_texture.as_ref().unwrap())
}
/// Acquire the next frame from the desktop
fn acquire_frame(&mut self, timeout_ms: u32) -> Result<Option<(ID3D11Texture2D, DXGI_OUTDUPL_FRAME_INFO)>> {
unsafe {
let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default();
let mut desktop_resource: Option<IDXGIResource> = None;
let result = self.duplication.AcquireNextFrame(
timeout_ms,
&mut frame_info,
&mut desktop_resource,
);
match result {
Ok(_) => {
let resource = desktop_resource.context("Desktop resource is None")?;
// Check if there's actually a new frame
if frame_info.LastPresentTime == 0 {
self.duplication.ReleaseFrame().ok();
return Ok(None);
}
let texture: ID3D11Texture2D = resource.cast()
.context("Failed to cast to ID3D11Texture2D")?;
Ok(Some((texture, frame_info)))
}
Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => {
// No new frame available
Ok(None)
}
Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST => {
// Desktop duplication was invalidated, need to recreate
tracing::warn!("Desktop duplication access lost, will need to recreate");
Err(anyhow::anyhow!("Access lost"))
}
Err(e) => {
Err(e).context("Failed to acquire frame")
}
}
}
}
/// Copy frame data to CPU-accessible memory
fn copy_frame_data(&mut self, texture: &ID3D11Texture2D) -> Result<Vec<u8>> {
unsafe {
// Get or create staging texture
let staging = self.get_staging_texture(texture)?.clone();
// Copy from GPU texture to staging texture
self.context.CopyResource(&staging, texture);
// Map the staging texture for CPU read
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
self.context
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))
.context("Failed to map staging texture")?;
// Copy pixel data
let src_pitch = mapped.RowPitch as usize;
let dst_pitch = (self.width * 4) as usize;
let height = self.height as usize;
let mut data = vec![0u8; dst_pitch * height];
let src_ptr = mapped.pData as *const u8;
for y in 0..height {
let src_row = src_ptr.add(y * src_pitch);
let dst_row = data.as_mut_ptr().add(y * dst_pitch);
ptr::copy_nonoverlapping(src_row, dst_row, dst_pitch);
}
// Unmap
self.context.Unmap(&staging, 0);
Ok(data)
}
}
/// Extract dirty rectangles from frame info
fn extract_dirty_rects(&self, _frame_info: &DXGI_OUTDUPL_FRAME_INFO) -> Option<Vec<DirtyRect>> {
// TODO: Implement dirty rectangle extraction using
// IDXGIOutputDuplication::GetFrameDirtyRects and GetFrameMoveRects
// For now, return None to indicate full frame update
None
}
}
impl Capturer for DxgiCapturer {
fn capture(&mut self) -> Result<Option<CapturedFrame>> {
// Try to acquire a frame with 100ms timeout
let frame_result = self.acquire_frame(100)?;
let (texture, frame_info) = match frame_result {
Some((t, f)) => (t, f),
None => return Ok(None), // No new frame
};
// Copy frame data to CPU memory
let data = self.copy_frame_data(&texture)?;
// Release the frame
unsafe {
self.duplication.ReleaseFrame().ok();
}
// Extract dirty rectangles if available
let dirty_rects = self.extract_dirty_rects(&frame_info);
Ok(Some(CapturedFrame {
width: self.width,
height: self.height,
data,
timestamp: Instant::now(),
display_id: self.display.id,
dirty_rects,
}))
}
fn display(&self) -> &Display {
&self.display
}
fn is_valid(&self) -> bool {
// Could check if duplication is still valid
true
}
}
impl Drop for DxgiCapturer {
fn drop(&mut self) {
// Release any held frame
unsafe {
self.duplication.ReleaseFrame().ok();
}
}
}

View File

@@ -0,0 +1,148 @@
//! GDI screen capture fallback
//!
//! Uses Windows GDI (Graphics Device Interface) for screen capture.
//! Slower than DXGI but works on older systems and edge cases.
use super::{CapturedFrame, Capturer, Display};
use anyhow::Result;
use std::time::Instant;
use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
GetDIBits, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS,
SRCCOPY, GetDC, ReleaseDC,
};
use windows::Win32::Foundation::HWND;
/// GDI-based screen capturer
pub struct GdiCapturer {
display: Display,
width: u32,
height: u32,
}
impl GdiCapturer {
/// Create a new GDI capturer for the specified display
pub fn new(display: Display) -> Result<Self> {
Ok(Self {
width: display.width,
height: display.height,
display,
})
}
/// Capture the screen using GDI
fn capture_gdi(&self) -> Result<Vec<u8>> {
unsafe {
// Get device context for the entire screen
let screen_dc = GetDC(HWND::default());
if screen_dc.is_invalid() {
anyhow::bail!("Failed to get screen DC");
}
// Create compatible DC and bitmap
let mem_dc = CreateCompatibleDC(screen_dc);
if mem_dc.is_invalid() {
ReleaseDC(HWND::default(), screen_dc);
anyhow::bail!("Failed to create compatible DC");
}
let bitmap = CreateCompatibleBitmap(screen_dc, self.width as i32, self.height as i32);
if bitmap.is_invalid() {
DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
anyhow::bail!("Failed to create compatible bitmap");
}
// Select bitmap into memory DC
let old_bitmap = SelectObject(mem_dc, bitmap);
// Copy screen to memory DC
if let Err(e) = BitBlt(
mem_dc,
0,
0,
self.width as i32,
self.height as i32,
screen_dc,
self.display.x,
self.display.y,
SRCCOPY,
) {
SelectObject(mem_dc, old_bitmap);
DeleteObject(bitmap);
DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
anyhow::bail!("BitBlt failed: {}", e);
}
// Prepare bitmap info for GetDIBits
let mut bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: self.width as i32,
biHeight: -(self.height as i32), // Negative for top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
biSizeImage: 0,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
},
bmiColors: [Default::default()],
};
// Allocate buffer for pixel data
let buffer_size = (self.width * self.height * 4) as usize;
let mut data = vec![0u8; buffer_size];
// Get the bits
let lines = GetDIBits(
mem_dc,
bitmap,
0,
self.height,
Some(data.as_mut_ptr() as *mut _),
&mut bmi,
DIB_RGB_COLORS,
);
// Cleanup
SelectObject(mem_dc, old_bitmap);
DeleteObject(bitmap);
DeleteDC(mem_dc);
ReleaseDC(HWND::default(), screen_dc);
if lines == 0 {
anyhow::bail!("GetDIBits failed");
}
Ok(data)
}
}
}
impl Capturer for GdiCapturer {
fn capture(&mut self) -> Result<Option<CapturedFrame>> {
let data = self.capture_gdi()?;
Ok(Some(CapturedFrame {
width: self.width,
height: self.height,
data,
timestamp: Instant::now(),
display_id: self.display.id,
dirty_rects: None, // GDI doesn't provide dirty rects
}))
}
fn display(&self) -> &Display {
&self.display
}
fn is_valid(&self) -> bool {
true
}
}

View File

@@ -0,0 +1,102 @@
//! Screen capture module
//!
//! Provides DXGI Desktop Duplication for high-performance screen capture on Windows 8+,
//! with GDI fallback for legacy systems or edge cases.
#[cfg(windows)]
mod dxgi;
#[cfg(windows)]
mod gdi;
mod display;
pub use display::{Display, DisplayInfo};
use anyhow::Result;
use std::time::Instant;
/// Captured frame data
#[derive(Debug)]
pub struct CapturedFrame {
/// Frame width in pixels
pub width: u32,
/// Frame height in pixels
pub height: u32,
/// Raw BGRA pixel data (4 bytes per pixel)
pub data: Vec<u8>,
/// Timestamp when frame was captured
pub timestamp: Instant,
/// Display ID this frame is from
pub display_id: u32,
/// Regions that changed since last frame (if available)
pub dirty_rects: Option<Vec<DirtyRect>>,
}
/// Rectangular region that changed
#[derive(Debug, Clone, Copy)]
pub struct DirtyRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
/// Screen capturer trait
pub trait Capturer: Send {
/// Capture the next frame
///
/// Returns None if no new frame is available (screen unchanged)
fn capture(&mut self) -> Result<Option<CapturedFrame>>;
/// Get the current display info
fn display(&self) -> &Display;
/// Check if capturer is still valid (display may have changed)
fn is_valid(&self) -> bool;
}
/// Create a capturer for the specified display
#[cfg(windows)]
pub fn create_capturer(display: Display, use_dxgi: bool, gdi_fallback: bool) -> Result<Box<dyn Capturer>> {
if use_dxgi {
match dxgi::DxgiCapturer::new(display.clone()) {
Ok(capturer) => {
tracing::info!("Using DXGI Desktop Duplication for capture");
return Ok(Box::new(capturer));
}
Err(e) => {
tracing::warn!("DXGI capture failed: {}, trying fallback", e);
if !gdi_fallback {
return Err(e);
}
}
}
}
// GDI fallback
tracing::info!("Using GDI for capture");
Ok(Box::new(gdi::GdiCapturer::new(display)?))
}
#[cfg(not(windows))]
pub fn create_capturer(_display: Display, _use_dxgi: bool, _gdi_fallback: bool) -> Result<Box<dyn Capturer>> {
anyhow::bail!("Screen capture only supported on Windows")
}
/// Get all available displays
pub fn enumerate_displays() -> Result<Vec<Display>> {
display::enumerate_displays()
}
/// Get the primary display
pub fn primary_display() -> Result<Display> {
let displays = enumerate_displays()?;
displays
.into_iter()
.find(|d| d.is_primary)
.ok_or_else(|| anyhow::anyhow!("No primary display found"))
}

View File

@@ -0,0 +1,172 @@
//! Chat window for the agent
//!
//! Provides a simple chat interface for communication between
//! the technician and the end user.
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use tracing::{info, warn, error};
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::*;
#[cfg(windows)]
use windows::Win32::Foundation::*;
#[cfg(windows)]
use windows::Win32::Graphics::Gdi::*;
#[cfg(windows)]
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
#[cfg(windows)]
use windows::core::PCWSTR;
/// A chat message
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub id: String,
pub sender: String,
pub content: String,
pub timestamp: i64,
}
/// Commands that can be sent to the chat window
#[derive(Debug)]
pub enum ChatCommand {
Show,
Hide,
AddMessage(ChatMessage),
Close,
}
/// Controller for the chat window
pub struct ChatController {
command_tx: Sender<ChatCommand>,
message_rx: Arc<Mutex<Receiver<ChatMessage>>>,
_handle: thread::JoinHandle<()>,
}
impl ChatController {
/// Create a new chat controller (spawns chat window thread)
#[cfg(windows)]
pub fn new() -> Option<Self> {
let (command_tx, command_rx) = mpsc::channel::<ChatCommand>();
let (message_tx, message_rx) = mpsc::channel::<ChatMessage>();
let handle = thread::spawn(move || {
run_chat_window(command_rx, message_tx);
});
Some(Self {
command_tx,
message_rx: Arc::new(Mutex::new(message_rx)),
_handle: handle,
})
}
#[cfg(not(windows))]
pub fn new() -> Option<Self> {
warn!("Chat window not supported on this platform");
None
}
/// Show the chat window
pub fn show(&self) {
let _ = self.command_tx.send(ChatCommand::Show);
}
/// Hide the chat window
pub fn hide(&self) {
let _ = self.command_tx.send(ChatCommand::Hide);
}
/// Add a message to the chat window
pub fn add_message(&self, msg: ChatMessage) {
let _ = self.command_tx.send(ChatCommand::AddMessage(msg));
}
/// Check for outgoing messages from the user
pub fn poll_outgoing(&self) -> Option<ChatMessage> {
if let Ok(rx) = self.message_rx.lock() {
rx.try_recv().ok()
} else {
None
}
}
/// Close the chat window
pub fn close(&self) {
let _ = self.command_tx.send(ChatCommand::Close);
}
}
#[cfg(windows)]
fn run_chat_window(command_rx: Receiver<ChatCommand>, message_tx: Sender<ChatMessage>) {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
info!("Starting chat window thread");
// For now, we'll use a simple message box approach
// A full implementation would create a proper window with a text input
// Process commands
loop {
match command_rx.recv() {
Ok(ChatCommand::Show) => {
info!("Chat window: Show requested");
// Show a simple notification that chat is available
}
Ok(ChatCommand::Hide) => {
info!("Chat window: Hide requested");
}
Ok(ChatCommand::AddMessage(msg)) => {
info!("Chat message received: {} - {}", msg.sender, msg.content);
// Show the message to the user via a message box (simple implementation)
let title = format!("Message from {}", msg.sender);
let content = msg.content.clone();
// Spawn a thread to show the message box (non-blocking)
thread::spawn(move || {
show_message_box_internal(&title, &content);
});
}
Ok(ChatCommand::Close) => {
info!("Chat window: Close requested");
break;
}
Err(_) => {
// Channel closed
break;
}
}
}
}
#[cfg(windows)]
fn show_message_box_internal(title: &str, message: &str) {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let title_wide: Vec<u16> = OsStr::new(title)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let message_wide: Vec<u16> = OsStr::new(message)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
MessageBoxW(
None,
PCWSTR(message_wide.as_ptr()),
PCWSTR(title_wide.as_ptr()),
MB_OK | MB_ICONINFORMATION | MB_TOPMOST | MB_SETFOREGROUND,
);
}
}
#[cfg(not(windows))]
fn run_chat_window(_command_rx: Receiver<ChatCommand>, _message_tx: Sender<ChatMessage>) {
// No-op on non-Windows
}

View File

@@ -0,0 +1,459 @@
//! Agent configuration management
//!
//! Supports three configuration sources (in priority order):
//! 1. Embedded config (magic bytes appended to executable)
//! 2. Config file (guruconnect.toml or %ProgramData%\GuruConnect\agent.toml)
//! 3. Environment variables (fallback)
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
use tracing::{info, warn};
use uuid::Uuid;
/// Magic marker for embedded configuration (10 bytes)
const MAGIC_MARKER: &[u8] = b"GURUCONFIG";
/// Embedded configuration data (appended to executable)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddedConfig {
/// Server WebSocket URL
pub server_url: String,
/// API key for authentication
pub api_key: String,
/// Company/organization name
#[serde(default)]
pub company: Option<String>,
/// Site/location name
#[serde(default)]
pub site: Option<String>,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
}
/// Detected run mode based on filename
#[derive(Debug, Clone, PartialEq)]
pub enum RunMode {
/// Viewer-only installation (filename contains "Viewer")
Viewer,
/// Temporary support session (filename contains 6-digit code)
TempSupport(String),
/// Permanent agent with embedded config
PermanentAgent,
/// Unknown/default mode
Default,
}
/// Agent configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Server WebSocket URL (e.g., wss://connect.example.com/ws)
pub server_url: String,
/// Agent API key for authentication
pub api_key: String,
/// Unique agent identifier (generated on first run)
#[serde(default = "generate_agent_id")]
pub agent_id: String,
/// Optional hostname override
pub hostname_override: Option<String>,
/// Company/organization name (from embedded config)
#[serde(default)]
pub company: Option<String>,
/// Site/location name (from embedded config)
#[serde(default)]
pub site: Option<String>,
/// Tags for categorization (from embedded config)
#[serde(default)]
pub tags: Vec<String>,
/// Support code for one-time support sessions (set via command line or filename)
#[serde(skip)]
pub support_code: Option<String>,
/// Capture settings
#[serde(default)]
pub capture: CaptureConfig,
/// Encoding settings
#[serde(default)]
pub encoding: EncodingConfig,
}
fn generate_agent_id() -> String {
Uuid::new_v4().to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureConfig {
/// Target frames per second (1-60)
#[serde(default = "default_fps")]
pub fps: u32,
/// Use DXGI Desktop Duplication (recommended)
#[serde(default = "default_true")]
pub use_dxgi: bool,
/// Fall back to GDI if DXGI fails
#[serde(default = "default_true")]
pub gdi_fallback: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncodingConfig {
/// Preferred codec (auto, raw, vp9, h264)
#[serde(default = "default_codec")]
pub codec: String,
/// Quality (1-100, higher = better quality, more bandwidth)
#[serde(default = "default_quality")]
pub quality: u32,
/// Use hardware encoding if available
#[serde(default = "default_true")]
pub hardware_encoding: bool,
}
fn default_fps() -> u32 {
30
}
fn default_true() -> bool {
true
}
fn default_codec() -> String {
"auto".to_string()
}
fn default_quality() -> u32 {
75
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
fps: default_fps(),
use_dxgi: true,
gdi_fallback: true,
}
}
}
impl Default for EncodingConfig {
fn default() -> Self {
Self {
codec: default_codec(),
quality: default_quality(),
hardware_encoding: true,
}
}
}
impl Config {
/// Detect run mode from executable filename
pub fn detect_run_mode() -> RunMode {
let exe_path = match std::env::current_exe() {
Ok(p) => p,
Err(_) => return RunMode::Default,
};
let filename = match exe_path.file_stem() {
Some(s) => s.to_string_lossy().to_string(),
None => return RunMode::Default,
};
let filename_lower = filename.to_lowercase();
// Check for viewer mode
if filename_lower.contains("viewer") {
info!("Detected viewer mode from filename: {}", filename);
return RunMode::Viewer;
}
// Check for support code in filename (6-digit number)
if let Some(code) = Self::extract_support_code(&filename) {
info!("Detected support code from filename: {}", code);
return RunMode::TempSupport(code);
}
// Check for embedded config
if Self::has_embedded_config() {
info!("Detected embedded config in executable");
return RunMode::PermanentAgent;
}
RunMode::Default
}
/// Extract 6-digit support code from filename
fn extract_support_code(filename: &str) -> Option<String> {
// Look for patterns like "GuruConnect-123456" or "GuruConnect_123456"
for part in filename.split(|c| c == '-' || c == '_' || c == '.') {
let trimmed = part.trim();
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
return Some(trimmed.to_string());
}
}
// Check if last 6 characters are all digits
if filename.len() >= 6 {
let last_six = &filename[filename.len() - 6..];
if last_six.chars().all(|c| c.is_ascii_digit()) {
return Some(last_six.to_string());
}
}
None
}
/// Check if embedded configuration exists in the executable
pub fn has_embedded_config() -> bool {
Self::read_embedded_config().is_ok()
}
/// Read embedded configuration from the executable
pub fn read_embedded_config() -> Result<EmbeddedConfig> {
let exe_path = std::env::current_exe()
.context("Failed to get current executable path")?;
let mut file = std::fs::File::open(&exe_path)
.context("Failed to open executable for reading")?;
let file_size = file.metadata()?.len();
if file_size < (MAGIC_MARKER.len() + 4) as u64 {
return Err(anyhow!("File too small to contain embedded config"));
}
// Read the last part of the file to find magic marker
// Structure: [PE binary][GURUCONFIG][length:u32][json config]
// We need to search backwards from the end
// Read last 64KB (should be more than enough for config)
let search_size = std::cmp::min(65536, file_size as usize);
let search_start = file_size - search_size as u64;
file.seek(SeekFrom::Start(search_start))?;
let mut buffer = vec![0u8; search_size];
file.read_exact(&mut buffer)?;
// Find magic marker
let marker_pos = buffer.windows(MAGIC_MARKER.len())
.rposition(|window| window == MAGIC_MARKER)
.ok_or_else(|| anyhow!("Magic marker not found"))?;
// Read config length (4 bytes after marker)
let length_start = marker_pos + MAGIC_MARKER.len();
if length_start + 4 > buffer.len() {
return Err(anyhow!("Invalid embedded config: length field truncated"));
}
let config_length = u32::from_le_bytes([
buffer[length_start],
buffer[length_start + 1],
buffer[length_start + 2],
buffer[length_start + 3],
]) as usize;
// Read config data
let config_start = length_start + 4;
if config_start + config_length > buffer.len() {
return Err(anyhow!("Invalid embedded config: data truncated"));
}
let config_bytes = &buffer[config_start..config_start + config_length];
let config: EmbeddedConfig = serde_json::from_slice(config_bytes)
.context("Failed to parse embedded config JSON")?;
info!("Loaded embedded config: server={}, company={:?}",
config.server_url, config.company);
Ok(config)
}
/// Check if an explicit agent configuration file exists
/// This returns true only if there's a real config file, not generated defaults
pub fn has_agent_config() -> bool {
// Check for embedded config first
if Self::has_embedded_config() {
return true;
}
// Check for config in current directory
let local_config = PathBuf::from("guruconnect.toml");
if local_config.exists() {
return true;
}
// Check in program data directory (Windows)
#[cfg(windows)]
{
if let Ok(program_data) = std::env::var("ProgramData") {
let path = PathBuf::from(program_data)
.join("GuruConnect")
.join("agent.toml");
if path.exists() {
return true;
}
}
}
false
}
/// Load configuration from embedded config, file, or environment
pub fn load() -> Result<Self> {
// Priority 1: Try loading from embedded config
if let Ok(embedded) = Self::read_embedded_config() {
info!("Using embedded configuration");
let config = Config {
server_url: embedded.server_url,
api_key: embedded.api_key,
agent_id: generate_agent_id(),
hostname_override: None,
company: embedded.company,
site: embedded.site,
tags: embedded.tags,
support_code: None,
capture: CaptureConfig::default(),
encoding: EncodingConfig::default(),
};
// Save to file for persistence (so agent_id is preserved)
let _ = config.save();
return Ok(config);
}
// Priority 2: Try loading from config file
let config_path = Self::config_path();
if config_path.exists() {
let contents = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from {:?}", config_path))?;
let mut config: Config = toml::from_str(&contents)
.with_context(|| "Failed to parse config file")?;
// Ensure agent_id is set and saved
if config.agent_id.is_empty() {
config.agent_id = generate_agent_id();
let _ = config.save();
}
// support_code is always None when loading from file (set via CLI)
config.support_code = None;
return Ok(config);
}
// Priority 3: Fall back to environment variables
let server_url = std::env::var("GURUCONNECT_SERVER_URL")
.unwrap_or_else(|_| "wss://connect.azcomputerguru.com/ws/agent".to_string());
let api_key = std::env::var("GURUCONNECT_API_KEY")
.unwrap_or_else(|_| "dev-key".to_string());
let agent_id = std::env::var("GURUCONNECT_AGENT_ID")
.unwrap_or_else(|_| generate_agent_id());
let config = Config {
server_url,
api_key,
agent_id,
hostname_override: std::env::var("GURUCONNECT_HOSTNAME").ok(),
company: None,
site: None,
tags: Vec::new(),
support_code: None,
capture: CaptureConfig::default(),
encoding: EncodingConfig::default(),
};
// Save config with generated agent_id for persistence
let _ = config.save();
Ok(config)
}
/// Get the configuration file path
fn config_path() -> PathBuf {
// Check for config in current directory first
let local_config = PathBuf::from("guruconnect.toml");
if local_config.exists() {
return local_config;
}
// Check in program data directory (Windows)
#[cfg(windows)]
{
if let Ok(program_data) = std::env::var("ProgramData") {
let path = PathBuf::from(program_data)
.join("GuruConnect")
.join("agent.toml");
if path.exists() {
return path;
}
}
}
// Default to local config
local_config
}
/// Get the hostname to use
pub fn hostname(&self) -> String {
self.hostname_override
.clone()
.unwrap_or_else(|| {
hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string())
})
}
/// Save current configuration to file
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path();
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
std::fs::write(&config_path, contents)?;
Ok(())
}
}
/// Example configuration file content
pub fn example_config() -> &'static str {
r#"# GuruConnect Agent Configuration
# Server connection
server_url = "wss://connect.example.com/ws"
api_key = "your-agent-api-key"
agent_id = "auto-generated-uuid"
# Optional: override hostname
# hostname_override = "custom-hostname"
[capture]
fps = 30
use_dxgi = true
gdi_fallback = true
[encoding]
codec = "auto" # auto, raw, vp9, h264
quality = 75 # 1-100
hardware_encoding = true
"#
}

View File

@@ -0,0 +1,52 @@
//! Frame encoding module
//!
//! Encodes captured frames for transmission. Supports:
//! - Raw BGRA + Zstd compression (lowest latency, LAN mode)
//! - VP9 software encoding (universal fallback)
//! - H264 hardware encoding (when GPU available)
mod raw;
pub use raw::RawEncoder;
use crate::capture::CapturedFrame;
use crate::proto::{VideoFrame, RawFrame, DirtyRect as ProtoDirtyRect};
use anyhow::Result;
/// Encoded frame ready for transmission
#[derive(Debug)]
pub struct EncodedFrame {
/// Protobuf video frame message
pub frame: VideoFrame,
/// Size in bytes after encoding
pub size: usize,
/// Whether this is a keyframe (full frame)
pub is_keyframe: bool,
}
/// Frame encoder trait
pub trait Encoder: Send {
/// Encode a captured frame
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame>;
/// Request a keyframe on next encode
fn request_keyframe(&mut self);
/// Get encoder name/type
fn name(&self) -> &str;
}
/// Create an encoder based on configuration
pub fn create_encoder(codec: &str, quality: u32) -> Result<Box<dyn Encoder>> {
match codec.to_lowercase().as_str() {
"raw" | "zstd" => Ok(Box::new(RawEncoder::new(quality)?)),
// "vp9" => Ok(Box::new(Vp9Encoder::new(quality)?)),
// "h264" => Ok(Box::new(H264Encoder::new(quality)?)),
"auto" | _ => {
// Default to raw for now (best for LAN)
Ok(Box::new(RawEncoder::new(quality)?))
}
}
}

View File

@@ -0,0 +1,232 @@
//! Raw frame encoder with Zstd compression
//!
//! Best for LAN connections where bandwidth is plentiful and latency is critical.
//! Compresses BGRA pixel data using Zstd for fast compression/decompression.
use super::{EncodedFrame, Encoder};
use crate::capture::{CapturedFrame, DirtyRect};
use crate::proto::{video_frame, DirtyRect as ProtoDirtyRect, RawFrame, VideoFrame};
use anyhow::Result;
/// Raw frame encoder with Zstd compression
pub struct RawEncoder {
/// Compression level (1-22, default 3 for speed)
compression_level: i32,
/// Previous frame for delta detection
previous_frame: Option<Vec<u8>>,
/// Force keyframe on next encode
force_keyframe: bool,
/// Frame counter
sequence: u32,
}
impl RawEncoder {
/// Create a new raw encoder
///
/// Quality 1-100 maps to Zstd compression level:
/// - Low quality (1-33): Level 1-3 (fastest)
/// - Medium quality (34-66): Level 4-9
/// - High quality (67-100): Level 10-15 (best compression)
pub fn new(quality: u32) -> Result<Self> {
let compression_level = Self::quality_to_level(quality);
Ok(Self {
compression_level,
previous_frame: None,
force_keyframe: true, // Start with keyframe
sequence: 0,
})
}
/// Convert quality (1-100) to Zstd compression level
fn quality_to_level(quality: u32) -> i32 {
// Lower quality = faster compression (level 1-3)
// Higher quality = better compression (level 10-15)
// We optimize for speed, so cap at 6
match quality {
0..=33 => 1,
34..=50 => 2,
51..=66 => 3,
67..=80 => 4,
81..=90 => 5,
_ => 6,
}
}
/// Compress data using Zstd
fn compress(&self, data: &[u8]) -> Result<Vec<u8>> {
let compressed = zstd::encode_all(data, self.compression_level)?;
Ok(compressed)
}
/// Detect dirty rectangles by comparing with previous frame
fn detect_dirty_rects(
&self,
current: &[u8],
previous: &[u8],
width: u32,
height: u32,
) -> Vec<DirtyRect> {
// Simple block-based dirty detection
// Divide screen into 64x64 blocks and check which changed
const BLOCK_SIZE: u32 = 64;
let mut dirty_rects = Vec::new();
let stride = (width * 4) as usize;
let blocks_x = (width + BLOCK_SIZE - 1) / BLOCK_SIZE;
let blocks_y = (height + BLOCK_SIZE - 1) / BLOCK_SIZE;
for by in 0..blocks_y {
for bx in 0..blocks_x {
let x = bx * BLOCK_SIZE;
let y = by * BLOCK_SIZE;
let block_w = (BLOCK_SIZE).min(width - x);
let block_h = (BLOCK_SIZE).min(height - y);
// Check if this block changed
let mut changed = false;
'block_check: for row in 0..block_h {
let row_start = ((y + row) as usize * stride) + (x as usize * 4);
let row_end = row_start + (block_w as usize * 4);
if row_end <= current.len() && row_end <= previous.len() {
if current[row_start..row_end] != previous[row_start..row_end] {
changed = true;
break 'block_check;
}
} else {
changed = true;
break 'block_check;
}
}
if changed {
dirty_rects.push(DirtyRect {
x,
y,
width: block_w,
height: block_h,
});
}
}
}
// Merge adjacent dirty rects (simple optimization)
// TODO: Implement proper rectangle merging
dirty_rects
}
/// Extract pixels for dirty rectangles only
fn extract_dirty_pixels(
&self,
data: &[u8],
width: u32,
dirty_rects: &[DirtyRect],
) -> Vec<u8> {
let stride = (width * 4) as usize;
let mut pixels = Vec::new();
for rect in dirty_rects {
for row in 0..rect.height {
let row_start = ((rect.y + row) as usize * stride) + (rect.x as usize * 4);
let row_end = row_start + (rect.width as usize * 4);
if row_end <= data.len() {
pixels.extend_from_slice(&data[row_start..row_end]);
}
}
}
pixels
}
}
impl Encoder for RawEncoder {
fn encode(&mut self, frame: &CapturedFrame) -> Result<EncodedFrame> {
self.sequence = self.sequence.wrapping_add(1);
let is_keyframe = self.force_keyframe || self.previous_frame.is_none();
self.force_keyframe = false;
let (data_to_compress, dirty_rects, full_frame) = if is_keyframe {
// Keyframe: send full frame
(frame.data.clone(), Vec::new(), true)
} else if let Some(ref previous) = self.previous_frame {
// Delta frame: detect and send only changed regions
let dirty_rects =
self.detect_dirty_rects(&frame.data, previous, frame.width, frame.height);
if dirty_rects.is_empty() {
// No changes, skip frame
return Ok(EncodedFrame {
frame: VideoFrame::default(),
size: 0,
is_keyframe: false,
});
}
// If too many dirty rects, just send full frame
if dirty_rects.len() > 50 {
(frame.data.clone(), Vec::new(), true)
} else {
let dirty_pixels = self.extract_dirty_pixels(&frame.data, frame.width, &dirty_rects);
(dirty_pixels, dirty_rects, false)
}
} else {
(frame.data.clone(), Vec::new(), true)
};
// Compress the data
let compressed = self.compress(&data_to_compress)?;
let size = compressed.len();
// Build protobuf message
let proto_dirty_rects: Vec<ProtoDirtyRect> = dirty_rects
.iter()
.map(|r| ProtoDirtyRect {
x: r.x as i32,
y: r.y as i32,
width: r.width as i32,
height: r.height as i32,
})
.collect();
let raw_frame = RawFrame {
width: frame.width as i32,
height: frame.height as i32,
data: compressed,
compressed: true,
dirty_rects: proto_dirty_rects,
is_keyframe: full_frame,
};
let video_frame = VideoFrame {
timestamp: frame.timestamp.elapsed().as_millis() as i64,
display_id: frame.display_id as i32,
sequence: self.sequence as i32,
encoding: Some(video_frame::Encoding::Raw(raw_frame)),
};
// Save current frame for next comparison
self.previous_frame = Some(frame.data.clone());
Ok(EncodedFrame {
frame: video_frame,
size,
is_keyframe: full_frame,
})
}
fn request_keyframe(&mut self) {
self.force_keyframe = true;
}
fn name(&self) -> &str {
"raw+zstd"
}
}

View File

@@ -0,0 +1,296 @@
//! Keyboard input simulation using Windows SendInput API
use anyhow::Result;
#[cfg(windows)]
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY,
KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, KEYBDINPUT,
MapVirtualKeyW, MAPVK_VK_TO_VSC_EX,
};
/// Keyboard input controller
pub struct KeyboardController {
// Track modifier states for proper handling
#[allow(dead_code)]
modifiers: ModifierState,
}
#[derive(Default)]
struct ModifierState {
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
}
impl KeyboardController {
/// Create a new keyboard controller
pub fn new() -> Result<Self> {
Ok(Self {
modifiers: ModifierState::default(),
})
}
/// Press a key down by virtual key code
#[cfg(windows)]
pub fn key_down(&mut self, vk_code: u16) -> Result<()> {
self.send_key(vk_code, true)
}
/// Release a key by virtual key code
#[cfg(windows)]
pub fn key_up(&mut self, vk_code: u16) -> Result<()> {
self.send_key(vk_code, false)
}
/// Send a key event
#[cfg(windows)]
fn send_key(&mut self, vk_code: u16, down: bool) -> Result<()> {
// Get scan code from virtual key
let scan_code = unsafe { MapVirtualKeyW(vk_code as u32, MAPVK_VK_TO_VSC_EX) as u16 };
let mut flags = KEYBD_EVENT_FLAGS::default();
// Add extended key flag for certain keys
if Self::is_extended_key(vk_code) || (scan_code >> 8) == 0xE0 {
flags |= KEYEVENTF_EXTENDEDKEY;
}
if !down {
flags |= KEYEVENTF_KEYUP;
}
let input = INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(vk_code),
wScan: scan_code,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
self.send_input(&[input])
}
/// Type a unicode character
#[cfg(windows)]
pub fn type_char(&mut self, ch: char) -> Result<()> {
let mut inputs = Vec::new();
let mut buf = [0u16; 2];
let encoded = ch.encode_utf16(&mut buf);
// For characters that fit in a single u16
for &code_unit in encoded.iter() {
// Key down
inputs.push(INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: code_unit,
dwFlags: KEYEVENTF_UNICODE,
time: 0,
dwExtraInfo: 0,
},
},
});
// Key up
inputs.push(INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: code_unit,
dwFlags: KEYEVENTF_UNICODE | KEYEVENTF_KEYUP,
time: 0,
dwExtraInfo: 0,
},
},
});
}
self.send_input(&inputs)
}
/// Type a string of text
#[cfg(windows)]
pub fn type_string(&mut self, text: &str) -> Result<()> {
for ch in text.chars() {
self.type_char(ch)?;
}
Ok(())
}
/// Send Secure Attention Sequence (Ctrl+Alt+Delete)
///
/// This uses a multi-tier approach:
/// 1. Try the GuruConnect SAS Service (runs as SYSTEM, handles via named pipe)
/// 2. Try the sas.dll directly (requires SYSTEM privileges)
/// 3. Fallback to key simulation (won't work on secure desktop)
#[cfg(windows)]
pub fn send_sas(&mut self) -> Result<()> {
// Tier 1: Try the SAS service (named pipe IPC to SYSTEM service)
if let Ok(()) = crate::sas_client::request_sas() {
tracing::info!("SAS sent via GuruConnect SAS Service");
return Ok(());
}
tracing::info!("SAS service not available, trying direct sas.dll...");
// Tier 2: Try using the sas.dll directly (requires SYSTEM privileges)
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
use windows::core::PCWSTR;
unsafe {
let dll_name: Vec<u16> = "sas.dll\0".encode_utf16().collect();
let lib = LoadLibraryW(PCWSTR(dll_name.as_ptr()));
if let Ok(lib) = lib {
let proc_name = b"SendSAS\0";
if let Some(proc) = GetProcAddress(lib, windows::core::PCSTR(proc_name.as_ptr())) {
// SendSAS takes a BOOL parameter: FALSE for Ctrl+Alt+Del
let send_sas: extern "system" fn(i32) = std::mem::transmute(proc);
send_sas(0); // FALSE = Ctrl+Alt+Del
tracing::info!("SAS sent via direct sas.dll call");
return Ok(());
}
}
}
// Tier 3: Fallback - try sending the keys (won't work on secure desktop)
tracing::warn!("SAS service and sas.dll not available, Ctrl+Alt+Del may not work");
// VK codes
const VK_CONTROL: u16 = 0x11;
const VK_MENU: u16 = 0x12; // Alt
const VK_DELETE: u16 = 0x2E;
// Press keys
self.key_down(VK_CONTROL)?;
self.key_down(VK_MENU)?;
self.key_down(VK_DELETE)?;
// Release keys
self.key_up(VK_DELETE)?;
self.key_up(VK_MENU)?;
self.key_up(VK_CONTROL)?;
Ok(())
}
/// Check if a virtual key code is an extended key
#[cfg(windows)]
fn is_extended_key(vk: u16) -> bool {
matches!(
vk,
0x21..=0x28 | // Page Up, Page Down, End, Home, Arrow keys
0x2D | 0x2E | // Insert, Delete
0x5B | 0x5C | // Left/Right Windows keys
0x5D | // Applications key
0x6F | // Numpad Divide
0x90 | // Num Lock
0x91 // Scroll Lock
)
}
/// Send input events
#[cfg(windows)]
fn send_input(&self, inputs: &[INPUT]) -> Result<()> {
let sent = unsafe { SendInput(inputs, std::mem::size_of::<INPUT>() as i32) };
if sent as usize != inputs.len() {
anyhow::bail!(
"SendInput failed: sent {} of {} inputs",
sent,
inputs.len()
);
}
Ok(())
}
#[cfg(not(windows))]
pub fn key_down(&mut self, _vk_code: u16) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))]
pub fn key_up(&mut self, _vk_code: u16) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))]
pub fn type_char(&mut self, _ch: char) -> Result<()> {
anyhow::bail!("Keyboard input only supported on Windows")
}
#[cfg(not(windows))]
pub fn send_sas(&mut self) -> Result<()> {
anyhow::bail!("SAS only supported on Windows")
}
}
/// Common Windows virtual key codes
#[allow(dead_code)]
pub mod vk {
pub const BACK: u16 = 0x08;
pub const TAB: u16 = 0x09;
pub const RETURN: u16 = 0x0D;
pub const SHIFT: u16 = 0x10;
pub const CONTROL: u16 = 0x11;
pub const MENU: u16 = 0x12; // Alt
pub const PAUSE: u16 = 0x13;
pub const CAPITAL: u16 = 0x14; // Caps Lock
pub const ESCAPE: u16 = 0x1B;
pub const SPACE: u16 = 0x20;
pub const PRIOR: u16 = 0x21; // Page Up
pub const NEXT: u16 = 0x22; // Page Down
pub const END: u16 = 0x23;
pub const HOME: u16 = 0x24;
pub const LEFT: u16 = 0x25;
pub const UP: u16 = 0x26;
pub const RIGHT: u16 = 0x27;
pub const DOWN: u16 = 0x28;
pub const INSERT: u16 = 0x2D;
pub const DELETE: u16 = 0x2E;
// 0-9 keys
pub const KEY_0: u16 = 0x30;
pub const KEY_9: u16 = 0x39;
// A-Z keys
pub const KEY_A: u16 = 0x41;
pub const KEY_Z: u16 = 0x5A;
// Windows keys
pub const LWIN: u16 = 0x5B;
pub const RWIN: u16 = 0x5C;
// Function keys
pub const F1: u16 = 0x70;
pub const F2: u16 = 0x71;
pub const F3: u16 = 0x72;
pub const F4: u16 = 0x73;
pub const F5: u16 = 0x74;
pub const F6: u16 = 0x75;
pub const F7: u16 = 0x76;
pub const F8: u16 = 0x77;
pub const F9: u16 = 0x78;
pub const F10: u16 = 0x79;
pub const F11: u16 = 0x7A;
pub const F12: u16 = 0x7B;
// Modifier keys
pub const LSHIFT: u16 = 0xA0;
pub const RSHIFT: u16 = 0xA1;
pub const LCONTROL: u16 = 0xA2;
pub const RCONTROL: u16 = 0xA3;
pub const LMENU: u16 = 0xA4; // Left Alt
pub const RMENU: u16 = 0xA5; // Right Alt
}

View File

@@ -0,0 +1,91 @@
//! Input injection module
//!
//! Handles mouse and keyboard input simulation using Windows SendInput API.
mod mouse;
mod keyboard;
pub use mouse::MouseController;
pub use keyboard::KeyboardController;
use anyhow::Result;
/// Combined input controller for mouse and keyboard
pub struct InputController {
mouse: MouseController,
keyboard: KeyboardController,
}
impl InputController {
/// Create a new input controller
pub fn new() -> Result<Self> {
Ok(Self {
mouse: MouseController::new()?,
keyboard: KeyboardController::new()?,
})
}
/// Get mouse controller
pub fn mouse(&mut self) -> &mut MouseController {
&mut self.mouse
}
/// Get keyboard controller
pub fn keyboard(&mut self) -> &mut KeyboardController {
&mut self.keyboard
}
/// Move mouse to absolute position
pub fn mouse_move(&mut self, x: i32, y: i32) -> Result<()> {
self.mouse.move_to(x, y)
}
/// Click mouse button
pub fn mouse_click(&mut self, button: MouseButton, down: bool) -> Result<()> {
if down {
self.mouse.button_down(button)
} else {
self.mouse.button_up(button)
}
}
/// Scroll mouse wheel
pub fn mouse_scroll(&mut self, delta_x: i32, delta_y: i32) -> Result<()> {
self.mouse.scroll(delta_x, delta_y)
}
/// Press or release a key
pub fn key_event(&mut self, vk_code: u16, down: bool) -> Result<()> {
if down {
self.keyboard.key_down(vk_code)
} else {
self.keyboard.key_up(vk_code)
}
}
/// Type a unicode character
pub fn type_unicode(&mut self, ch: char) -> Result<()> {
self.keyboard.type_char(ch)
}
/// Send Ctrl+Alt+Delete (requires special handling on Windows)
pub fn send_ctrl_alt_del(&mut self) -> Result<()> {
self.keyboard.send_sas()
}
}
/// Mouse button types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseButton {
Left,
Right,
Middle,
X1,
X2,
}
impl Default for InputController {
fn default() -> Self {
Self::new().expect("Failed to create input controller")
}
}

View File

@@ -0,0 +1,223 @@
//! Mouse input simulation using Windows SendInput API
use super::MouseButton;
use anyhow::Result;
#[cfg(windows)]
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL,
MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP,
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT,
};
// X button constants (not exported in windows crate 0.58+)
#[cfg(windows)]
const XBUTTON1: u32 = 0x0001;
#[cfg(windows)]
const XBUTTON2: u32 = 0x0002;
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
SM_YVIRTUALSCREEN,
};
/// Mouse input controller
pub struct MouseController {
/// Virtual screen dimensions for coordinate translation
#[cfg(windows)]
virtual_screen: VirtualScreen,
}
#[cfg(windows)]
struct VirtualScreen {
x: i32,
y: i32,
width: i32,
height: i32,
}
impl MouseController {
/// Create a new mouse controller
pub fn new() -> Result<Self> {
#[cfg(windows)]
{
let virtual_screen = unsafe {
VirtualScreen {
x: GetSystemMetrics(SM_XVIRTUALSCREEN),
y: GetSystemMetrics(SM_YVIRTUALSCREEN),
width: GetSystemMetrics(SM_CXVIRTUALSCREEN),
height: GetSystemMetrics(SM_CYVIRTUALSCREEN),
}
};
Ok(Self { virtual_screen })
}
#[cfg(not(windows))]
{
anyhow::bail!("Mouse input only supported on Windows")
}
}
/// Move mouse to absolute screen coordinates
#[cfg(windows)]
pub fn move_to(&mut self, x: i32, y: i32) -> Result<()> {
// Convert screen coordinates to normalized absolute coordinates (0-65535)
let norm_x = ((x - self.virtual_screen.x) * 65535) / self.virtual_screen.width;
let norm_y = ((y - self.virtual_screen.y) * 65535) / self.virtual_screen.height;
let input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: norm_x,
dy: norm_y,
mouseData: 0,
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
time: 0,
dwExtraInfo: 0,
},
},
};
self.send_input(&[input])
}
/// Press mouse button down
#[cfg(windows)]
pub fn button_down(&mut self, button: MouseButton) -> Result<()> {
let (flags, data) = match button {
MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, 0),
MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, 0),
MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, 0),
MouseButton::X1 => (MOUSEEVENTF_XDOWN, XBUTTON1),
MouseButton::X2 => (MOUSEEVENTF_XDOWN, XBUTTON2),
};
let input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: 0,
dy: 0,
mouseData: data,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
self.send_input(&[input])
}
/// Release mouse button
#[cfg(windows)]
pub fn button_up(&mut self, button: MouseButton) -> Result<()> {
let (flags, data) = match button {
MouseButton::Left => (MOUSEEVENTF_LEFTUP, 0),
MouseButton::Right => (MOUSEEVENTF_RIGHTUP, 0),
MouseButton::Middle => (MOUSEEVENTF_MIDDLEUP, 0),
MouseButton::X1 => (MOUSEEVENTF_XUP, XBUTTON1),
MouseButton::X2 => (MOUSEEVENTF_XUP, XBUTTON2),
};
let input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: 0,
dy: 0,
mouseData: data,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
self.send_input(&[input])
}
/// Scroll mouse wheel
#[cfg(windows)]
pub fn scroll(&mut self, delta_x: i32, delta_y: i32) -> Result<()> {
let mut inputs = Vec::new();
// Vertical scroll
if delta_y != 0 {
inputs.push(INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: 0,
dy: 0,
mouseData: delta_y as u32,
dwFlags: MOUSEEVENTF_WHEEL,
time: 0,
dwExtraInfo: 0,
},
},
});
}
// Horizontal scroll
if delta_x != 0 {
inputs.push(INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: 0,
dy: 0,
mouseData: delta_x as u32,
dwFlags: MOUSEEVENTF_HWHEEL,
time: 0,
dwExtraInfo: 0,
},
},
});
}
if !inputs.is_empty() {
self.send_input(&inputs)?;
}
Ok(())
}
/// Send input events
#[cfg(windows)]
fn send_input(&self, inputs: &[INPUT]) -> Result<()> {
let sent = unsafe {
SendInput(inputs, std::mem::size_of::<INPUT>() as i32)
};
if sent as usize != inputs.len() {
anyhow::bail!("SendInput failed: sent {} of {} inputs", sent, inputs.len());
}
Ok(())
}
#[cfg(not(windows))]
pub fn move_to(&mut self, _x: i32, _y: i32) -> Result<()> {
anyhow::bail!("Mouse input only supported on Windows")
}
#[cfg(not(windows))]
pub fn button_down(&mut self, _button: MouseButton) -> Result<()> {
anyhow::bail!("Mouse input only supported on Windows")
}
#[cfg(not(windows))]
pub fn button_up(&mut self, _button: MouseButton) -> Result<()> {
anyhow::bail!("Mouse input only supported on Windows")
}
#[cfg(not(windows))]
pub fn scroll(&mut self, _delta_x: i32, _delta_y: i32) -> Result<()> {
anyhow::bail!("Mouse input only supported on Windows")
}
}

View File

@@ -0,0 +1,417 @@
//! Installation and protocol handler registration
//!
//! Handles:
//! - Self-installation to Program Files (with UAC) or LocalAppData (fallback)
//! - Protocol handler registration (guruconnect://)
//! - UAC elevation with graceful fallback
use anyhow::{anyhow, Result};
use tracing::{info, warn, error};
#[cfg(windows)]
use windows::{
core::PCWSTR,
Win32::Foundation::HANDLE,
Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY},
Win32::System::Threading::{GetCurrentProcess, OpenProcessToken},
Win32::System::Registry::{
RegCreateKeyExW, RegSetValueExW, RegCloseKey, HKEY, HKEY_CLASSES_ROOT,
HKEY_CURRENT_USER, KEY_WRITE, REG_SZ, REG_OPTION_NON_VOLATILE,
},
Win32::UI::Shell::ShellExecuteW,
Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
};
#[cfg(windows)]
use std::ffi::OsStr;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
/// Install locations
pub const SYSTEM_INSTALL_PATH: &str = r"C:\Program Files\GuruConnect";
pub const USER_INSTALL_PATH: &str = r"GuruConnect"; // Relative to %LOCALAPPDATA%
/// Check if running with elevated privileges
#[cfg(windows)]
pub fn is_elevated() -> bool {
unsafe {
let mut token_handle = HANDLE::default();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() {
return false;
}
let mut elevation = TOKEN_ELEVATION::default();
let mut size = std::mem::size_of::<TOKEN_ELEVATION>() as u32;
let result = GetTokenInformation(
token_handle,
TokenElevation,
Some(&mut elevation as *mut _ as *mut _),
size,
&mut size,
);
let _ = windows::Win32::Foundation::CloseHandle(token_handle);
result.is_ok() && elevation.TokenIsElevated != 0
}
}
#[cfg(not(windows))]
pub fn is_elevated() -> bool {
unsafe { libc::geteuid() == 0 }
}
/// Get the install path based on elevation status
pub fn get_install_path(elevated: bool) -> std::path::PathBuf {
if elevated {
std::path::PathBuf::from(SYSTEM_INSTALL_PATH)
} else {
let local_app_data = std::env::var("LOCALAPPDATA")
.unwrap_or_else(|_| {
let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string());
format!(r"{}\AppData\Local", home)
});
std::path::PathBuf::from(local_app_data).join(USER_INSTALL_PATH)
}
}
/// Get the executable path
pub fn get_exe_path(install_path: &std::path::Path) -> std::path::PathBuf {
install_path.join("guruconnect.exe")
}
/// Attempt to elevate and re-run with install command
#[cfg(windows)]
pub fn try_elevate_and_install() -> Result<bool> {
let exe_path = std::env::current_exe()?;
let exe_path_wide: Vec<u16> = OsStr::new(exe_path.as_os_str())
.encode_wide()
.chain(std::iter::once(0))
.collect();
let verb: Vec<u16> = OsStr::new("runas")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let params: Vec<u16> = OsStr::new("install --elevated")
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let result = ShellExecuteW(
None,
PCWSTR(verb.as_ptr()),
PCWSTR(exe_path_wide.as_ptr()),
PCWSTR(params.as_ptr()),
PCWSTR::null(),
SW_SHOWNORMAL,
);
// ShellExecuteW returns > 32 on success
if result.0 as usize > 32 {
info!("UAC elevation requested");
Ok(true)
} else {
warn!("UAC elevation denied or failed");
Ok(false)
}
}
}
#[cfg(not(windows))]
pub fn try_elevate_and_install() -> Result<bool> {
Ok(false)
}
/// Register the guruconnect:// protocol handler
#[cfg(windows)]
pub fn register_protocol_handler(elevated: bool) -> Result<()> {
let install_path = get_install_path(elevated);
let exe_path = get_exe_path(&install_path);
let exe_path_str = exe_path.to_string_lossy();
// Command to execute: "C:\...\guruconnect.exe" "launch" "%1"
let command = format!("\"{}\" launch \"%1\"", exe_path_str);
// Choose registry root based on elevation
let root_key = if elevated {
HKEY_CLASSES_ROOT
} else {
// User-level registration under Software\Classes
HKEY_CURRENT_USER
};
let base_path = if elevated {
"guruconnect"
} else {
r"Software\Classes\guruconnect"
};
unsafe {
// Create guruconnect key
let mut protocol_key = HKEY::default();
let key_path = to_wide(base_path);
let result = RegCreateKeyExW(
root_key,
PCWSTR(key_path.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut protocol_key,
None,
);
if result.is_err() {
return Err(anyhow!("Failed to create protocol key: {:?}", result));
}
// Set default value (protocol description)
let description = to_wide("GuruConnect Protocol");
let result = RegSetValueExW(
protocol_key,
PCWSTR::null(),
0,
REG_SZ,
Some(&description_to_bytes(&description)),
);
if result.is_err() {
let _ = RegCloseKey(protocol_key);
return Err(anyhow!("Failed to set protocol description: {:?}", result));
}
// Set URL Protocol (empty string indicates this is a protocol handler)
let url_protocol = to_wide("URL Protocol");
let empty = to_wide("");
let result = RegSetValueExW(
protocol_key,
PCWSTR(url_protocol.as_ptr()),
0,
REG_SZ,
Some(&description_to_bytes(&empty)),
);
if result.is_err() {
let _ = RegCloseKey(protocol_key);
return Err(anyhow!("Failed to set URL Protocol: {:?}", result));
}
let _ = RegCloseKey(protocol_key);
// Create shell\open\command key
let command_path = if elevated {
r"guruconnect\shell\open\command"
} else {
r"Software\Classes\guruconnect\shell\open\command"
};
let command_key_path = to_wide(command_path);
let mut command_key = HKEY::default();
let result = RegCreateKeyExW(
root_key,
PCWSTR(command_key_path.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut command_key,
None,
);
if result.is_err() {
return Err(anyhow!("Failed to create command key: {:?}", result));
}
// Set the command
let command_wide = to_wide(&command);
let result = RegSetValueExW(
command_key,
PCWSTR::null(),
0,
REG_SZ,
Some(&description_to_bytes(&command_wide)),
);
if result.is_err() {
let _ = RegCloseKey(command_key);
return Err(anyhow!("Failed to set command: {:?}", result));
}
let _ = RegCloseKey(command_key);
}
info!("Protocol handler registered: guruconnect://");
Ok(())
}
#[cfg(not(windows))]
pub fn register_protocol_handler(_elevated: bool) -> Result<()> {
warn!("Protocol handler registration not supported on this platform");
Ok(())
}
/// Install the application
pub fn install(force_user_install: bool) -> Result<()> {
let elevated = is_elevated();
// If not elevated and not forcing user install, try to elevate
if !elevated && !force_user_install {
info!("Attempting UAC elevation for system-wide install...");
match try_elevate_and_install() {
Ok(true) => {
// Elevation was requested, exit this instance
// The elevated instance will continue the install
info!("Elevated process started, exiting current instance");
std::process::exit(0);
}
Ok(false) => {
info!("UAC denied, falling back to user install");
}
Err(e) => {
warn!("Elevation failed: {}, falling back to user install", e);
}
}
}
let install_path = get_install_path(elevated);
let exe_path = get_exe_path(&install_path);
info!("Installing to: {}", install_path.display());
// Create install directory
std::fs::create_dir_all(&install_path)?;
// Copy ourselves to install location
let current_exe = std::env::current_exe()?;
if current_exe != exe_path {
std::fs::copy(&current_exe, &exe_path)?;
info!("Copied executable to: {}", exe_path.display());
}
// Register protocol handler
register_protocol_handler(elevated)?;
info!("Installation complete!");
if elevated {
info!("Installed system-wide to: {}", install_path.display());
} else {
info!("Installed for current user to: {}", install_path.display());
}
Ok(())
}
/// Check if the guruconnect:// protocol handler is registered
#[cfg(windows)]
pub fn is_protocol_handler_registered() -> bool {
use windows::Win32::System::Registry::{
RegOpenKeyExW, RegCloseKey, HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, KEY_READ,
};
unsafe {
// Check system-wide registration (HKCR\guruconnect)
let mut key = HKEY::default();
let key_path = to_wide("guruconnect");
if RegOpenKeyExW(
HKEY_CLASSES_ROOT,
PCWSTR(key_path.as_ptr()),
0,
KEY_READ,
&mut key,
).is_ok() {
let _ = RegCloseKey(key);
return true;
}
// Check user-level registration (HKCU\Software\Classes\guruconnect)
let key_path = to_wide(r"Software\Classes\guruconnect");
if RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR(key_path.as_ptr()),
0,
KEY_READ,
&mut key,
).is_ok() {
let _ = RegCloseKey(key);
return true;
}
}
false
}
#[cfg(not(windows))]
pub fn is_protocol_handler_registered() -> bool {
// On non-Windows, assume not registered (or check ~/.local/share/applications)
false
}
/// Parse a guruconnect:// URL and extract session parameters
pub fn parse_protocol_url(url_str: &str) -> Result<(String, String, Option<String>)> {
// Expected formats:
// guruconnect://view/SESSION_ID
// guruconnect://view/SESSION_ID?token=API_KEY
// guruconnect://connect/SESSION_ID?server=wss://...&token=API_KEY
//
// Note: In URL parsing, "view" becomes the host, SESSION_ID is the path
let url = url::Url::parse(url_str)
.map_err(|e| anyhow!("Invalid URL: {}", e))?;
if url.scheme() != "guruconnect" {
return Err(anyhow!("Invalid scheme: expected guruconnect://"));
}
// The "action" (view/connect) is parsed as the host
let action = url.host_str()
.ok_or_else(|| anyhow!("Missing action in URL"))?;
// The session ID is the first path segment
let path = url.path().trim_start_matches('/');
info!("URL path: '{}', host: '{:?}'", path, url.host_str());
let session_id = if path.is_empty() {
return Err(anyhow!("Invalid URL: Missing session ID (path was empty, full URL: {})", url_str));
} else {
path.split('/').next().unwrap_or("").to_string()
};
if session_id.is_empty() {
return Err(anyhow!("Missing session ID"));
}
// Extract query parameters
let mut server = None;
let mut token = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"server" => server = Some(value.to_string()),
"token" | "api_key" => token = Some(value.to_string()),
_ => {}
}
}
// Default server if not specified
let server = server.unwrap_or_else(|| "wss://connect.azcomputerguru.com/ws/viewer".to_string());
match action {
"view" | "connect" => Ok((server, session_id, token)),
_ => Err(anyhow!("Unknown action: {}", action)),
}
}
// Helper functions for Windows registry operations
#[cfg(windows)]
fn to_wide(s: &str) -> Vec<u16> {
OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
#[cfg(windows)]
fn description_to_bytes(wide: &[u16]) -> Vec<u8> {
wide.iter()
.flat_map(|w| w.to_le_bytes())
.collect()
}

View File

@@ -0,0 +1,571 @@
//! GuruConnect - Remote Desktop Agent and Viewer
//!
//! Single binary for both agent (receiving connections) and viewer (initiating connections).
//!
//! Usage:
//! guruconnect agent - Run as background agent
//! guruconnect view <session_id> - View a remote session
//! guruconnect install - Install and register protocol handler
//! guruconnect launch <url> - Handle guruconnect:// URL
//! guruconnect [support_code] - Legacy: run agent with support code
// Hide console window by default on Windows (release builds)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod capture;
mod chat;
mod config;
mod encoder;
mod input;
mod install;
mod sas_client;
mod session;
mod startup;
mod transport;
mod tray;
mod update;
mod viewer;
pub mod proto {
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
}
/// Build information embedded at compile time
pub mod build_info {
/// Cargo package version (from Cargo.toml)
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Git commit hash (short, 8 chars)
pub const GIT_HASH: &str = env!("GIT_HASH");
/// Git commit hash (full)
pub const GIT_HASH_FULL: &str = env!("GIT_HASH_FULL");
/// Git branch name
pub const GIT_BRANCH: &str = env!("GIT_BRANCH");
/// Git dirty state ("clean" or "dirty")
pub const GIT_DIRTY: &str = env!("GIT_DIRTY");
/// Git commit date
pub const GIT_COMMIT_DATE: &str = env!("GIT_COMMIT_DATE");
/// Build timestamp (UTC)
pub const BUILD_TIMESTAMP: &str = env!("BUILD_TIMESTAMP");
/// Build profile (debug/release)
pub const BUILD_PROFILE: &str = env!("BUILD_PROFILE");
/// Target triple (e.g., x86_64-pc-windows-msvc)
pub const BUILD_TARGET: &str = env!("BUILD_TARGET");
/// Short version string for display (version + git hash)
pub fn short_version() -> String {
if GIT_DIRTY == "dirty" {
format!("{}-{}-dirty", VERSION, GIT_HASH)
} else {
format!("{}-{}", VERSION, GIT_HASH)
}
}
/// Full version string with all details
pub fn full_version() -> String {
format!(
"GuruConnect v{}\n\
Git: {} ({})\n\
Branch: {}\n\
Commit: {}\n\
Built: {}\n\
Profile: {}\n\
Target: {}",
VERSION,
GIT_HASH,
GIT_DIRTY,
GIT_BRANCH,
GIT_COMMIT_DATE,
BUILD_TIMESTAMP,
BUILD_PROFILE,
BUILD_TARGET
)
}
}
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing::{info, error, warn, Level};
use tracing_subscriber::FmtSubscriber;
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, MB_ICONERROR};
#[cfg(windows)]
use windows::core::PCWSTR;
#[cfg(windows)]
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
/// GuruConnect Remote Desktop
#[derive(Parser)]
#[command(name = "guruconnect")]
#[command(version = concat!(env!("CARGO_PKG_VERSION"), "-", env!("GIT_HASH")), about = "Remote desktop agent and viewer")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Support code for legacy mode (runs agent with code)
#[arg(value_name = "SUPPORT_CODE")]
support_code: Option<String>,
/// Enable verbose logging
#[arg(short, long, global = true)]
verbose: bool,
/// Internal flag: set after auto-update to trigger cleanup
#[arg(long, hide = true)]
post_update: bool,
}
#[derive(Subcommand)]
enum Commands {
/// Run as background agent (receive remote connections)
Agent {
/// Support code for one-time session
#[arg(short, long)]
code: Option<String>,
},
/// View a remote session (connect to an agent)
View {
/// Session ID to connect to
session_id: String,
/// Server URL
#[arg(short, long, default_value = "wss://connect.azcomputerguru.com/ws/viewer")]
server: String,
/// API key for authentication
#[arg(short, long, default_value = "")]
api_key: String,
},
/// Install GuruConnect and register protocol handler
Install {
/// Skip UAC elevation, install for current user only
#[arg(long)]
user_only: bool,
/// Called internally when running elevated
#[arg(long, hide = true)]
elevated: bool,
},
/// Uninstall GuruConnect
Uninstall,
/// Handle a guruconnect:// protocol URL
Launch {
/// The guruconnect:// URL to handle
url: String,
},
/// Show detailed version and build information
#[command(name = "version-info")]
VersionInfo,
}
fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize logging
let level = if cli.verbose { Level::DEBUG } else { Level::INFO };
FmtSubscriber::builder()
.with_max_level(level)
.with_target(true)
.with_thread_ids(true)
.init();
info!("GuruConnect {} ({})", build_info::short_version(), build_info::BUILD_TARGET);
info!("Built: {} | Commit: {}", build_info::BUILD_TIMESTAMP, build_info::GIT_COMMIT_DATE);
// Handle post-update cleanup
if cli.post_update {
info!("Post-update mode: cleaning up old executable");
update::cleanup_post_update();
}
match cli.command {
Some(Commands::Agent { code }) => {
run_agent_mode(code)
}
Some(Commands::View { session_id, server, api_key }) => {
run_viewer_mode(&server, &session_id, &api_key)
}
Some(Commands::Install { user_only, elevated }) => {
run_install(user_only || elevated)
}
Some(Commands::Uninstall) => {
run_uninstall()
}
Some(Commands::Launch { url }) => {
run_launch(&url)
}
Some(Commands::VersionInfo) => {
// Show detailed version info (allocate console on Windows for visibility)
#[cfg(windows)]
show_debug_console();
println!("{}", build_info::full_version());
Ok(())
}
None => {
// No subcommand - detect mode from filename or embedded config
// Legacy: if support_code arg provided, use that
if let Some(code) = cli.support_code {
return run_agent_mode(Some(code));
}
// Detect run mode from filename
use config::RunMode;
match config::Config::detect_run_mode() {
RunMode::Viewer => {
// Filename indicates viewer-only (e.g., "GuruConnect-Viewer.exe")
info!("Viewer mode detected from filename");
if !install::is_protocol_handler_registered() {
info!("Installing protocol handler for viewer");
run_install(false)
} else {
info!("Viewer already installed, nothing to do");
show_message_box("GuruConnect Viewer", "GuruConnect viewer is installed.\n\nUse guruconnect:// links to connect to remote sessions.");
Ok(())
}
}
RunMode::TempSupport(code) => {
// Filename contains support code (e.g., "GuruConnect-123456.exe")
info!("Temp support session detected from filename: {}", code);
run_agent_mode(Some(code))
}
RunMode::PermanentAgent => {
// Embedded config found - run as permanent agent
info!("Permanent agent mode detected (embedded config)");
if !install::is_protocol_handler_registered() {
// First run - install then run as agent
info!("First run - installing agent");
if let Err(e) = install::install(false) {
warn!("Installation failed: {}", e);
}
}
run_agent_mode(None)
}
RunMode::Default => {
// No special mode detected - use legacy logic
if !install::is_protocol_handler_registered() {
// Protocol handler not registered - user likely downloaded from web
info!("Protocol handler not registered, running installer");
run_install(false)
} else if config::Config::has_agent_config() {
// Has agent config - run as agent
info!("Agent config found, running as agent");
run_agent_mode(None)
} else {
// Viewer-only installation - just exit silently
info!("Viewer-only installation, exiting");
Ok(())
}
}
}
}
}
}
/// Run in agent mode (receive remote connections)
fn run_agent_mode(support_code: Option<String>) -> Result<()> {
info!("Running in agent mode");
// Check elevation status
if install::is_elevated() {
info!("Running with elevated (administrator) privileges");
} else {
info!("Running with standard user privileges");
}
// Load configuration
let mut config = config::Config::load()?;
// Set support code if provided
if let Some(code) = support_code {
info!("Support code: {}", code);
config.support_code = Some(code);
}
info!("Server: {}", config.server_url);
if let Some(ref company) = config.company {
info!("Company: {}", company);
}
if let Some(ref site) = config.site {
info!("Site: {}", site);
}
// Run the agent
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(run_agent(config))
}
/// Run in viewer mode (connect to remote session)
fn run_viewer_mode(server: &str, session_id: &str, api_key: &str) -> Result<()> {
info!("Running in viewer mode");
info!("Connecting to session: {}", session_id);
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(viewer::run(server, session_id, api_key))
}
/// Handle guruconnect:// URL launch
fn run_launch(url: &str) -> Result<()> {
info!("Handling protocol URL: {}", url);
match install::parse_protocol_url(url) {
Ok((server, session_id, token)) => {
let api_key = token.unwrap_or_default();
run_viewer_mode(&server, &session_id, &api_key)
}
Err(e) => {
error!("Failed to parse URL: {}", e);
show_error_box("GuruConnect", &format!("Invalid URL: {}", e));
Err(e)
}
}
}
/// Install GuruConnect
fn run_install(force_user_install: bool) -> Result<()> {
info!("Installing GuruConnect...");
match install::install(force_user_install) {
Ok(()) => {
show_message_box("GuruConnect", "Installation complete!\n\nYou can now use guruconnect:// links.");
Ok(())
}
Err(e) => {
error!("Installation failed: {}", e);
show_error_box("GuruConnect", &format!("Installation failed: {}", e));
Err(e)
}
}
}
/// Uninstall GuruConnect
fn run_uninstall() -> Result<()> {
info!("Uninstalling GuruConnect...");
// Remove from startup
if let Err(e) = startup::remove_from_startup() {
warn!("Failed to remove from startup: {}", e);
}
// TODO: Remove registry keys for protocol handler
// TODO: Remove install directory
show_message_box("GuruConnect", "Uninstall complete.");
Ok(())
}
/// Show a message box (Windows only)
#[cfg(windows)]
fn show_message_box(title: &str, message: &str) {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let title_wide: Vec<u16> = OsStr::new(title)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let message_wide: Vec<u16> = OsStr::new(message)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
MessageBoxW(
None,
PCWSTR(message_wide.as_ptr()),
PCWSTR(title_wide.as_ptr()),
MB_OK | MB_ICONINFORMATION,
);
}
}
#[cfg(not(windows))]
fn show_message_box(_title: &str, message: &str) {
println!("{}", message);
}
/// Show an error message box (Windows only)
#[cfg(windows)]
fn show_error_box(title: &str, message: &str) {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let title_wide: Vec<u16> = OsStr::new(title)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let message_wide: Vec<u16> = OsStr::new(message)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
MessageBoxW(
None,
PCWSTR(message_wide.as_ptr()),
PCWSTR(title_wide.as_ptr()),
MB_OK | MB_ICONERROR,
);
}
}
#[cfg(not(windows))]
fn show_error_box(_title: &str, message: &str) {
eprintln!("ERROR: {}", message);
}
/// Show debug console window (Windows only)
#[cfg(windows)]
#[allow(dead_code)]
fn show_debug_console() {
unsafe {
let hwnd = GetConsoleWindow();
if hwnd.0 == std::ptr::null_mut() {
let _ = AllocConsole();
} else {
let _ = ShowWindow(hwnd, SW_SHOW);
}
}
}
#[cfg(not(windows))]
#[allow(dead_code)]
fn show_debug_console() {}
/// Clean up before exiting
fn cleanup_on_exit() {
info!("Cleaning up before exit");
if let Err(e) = startup::remove_from_startup() {
warn!("Failed to remove from startup: {}", e);
}
}
/// Run the agent main loop
async fn run_agent(config: config::Config) -> Result<()> {
let elevated = install::is_elevated();
let mut session = session::SessionManager::new(config.clone(), elevated);
let is_support_session = config.support_code.is_some();
let hostname = config.hostname();
// Add to startup
if let Err(e) = startup::add_to_startup() {
warn!("Failed to add to startup: {}", e);
}
// Create tray icon
let tray = match tray::TrayController::new(&hostname, config.support_code.as_deref(), is_support_session) {
Ok(t) => {
info!("Tray icon created");
Some(t)
}
Err(e) => {
warn!("Failed to create tray icon: {}", e);
None
}
};
// Create chat controller
let chat_ctrl = chat::ChatController::new();
// Connect to server and run main loop
loop {
info!("Connecting to server...");
if is_support_session {
if let Some(ref t) = tray {
if t.exit_requested() {
info!("Exit requested by user");
cleanup_on_exit();
return Ok(());
}
}
}
match session.connect().await {
Ok(_) => {
info!("Connected to server");
if let Some(ref t) = tray {
t.update_status("Status: Connected");
}
if let Err(e) = session.run_with_tray(tray.as_ref(), chat_ctrl.as_ref()).await {
let error_msg = e.to_string();
if error_msg.contains("USER_EXIT") {
info!("Session ended by user");
cleanup_on_exit();
return Ok(());
}
if error_msg.contains("SESSION_CANCELLED") {
info!("Session was cancelled by technician");
cleanup_on_exit();
show_message_box("Support Session Ended", "The support session was cancelled.");
return Ok(());
}
if error_msg.contains("ADMIN_DISCONNECT") {
info!("Session disconnected by administrator - uninstalling");
if let Err(e) = startup::uninstall() {
warn!("Uninstall failed: {}", e);
}
show_message_box("Remote Session Ended", "The session was ended by the administrator.");
return Ok(());
}
if error_msg.contains("ADMIN_UNINSTALL") {
info!("Uninstall command received from server - uninstalling");
if let Err(e) = startup::uninstall() {
warn!("Uninstall failed: {}", e);
}
show_message_box("GuruConnect Removed", "This computer has been removed from remote management.");
return Ok(());
}
if error_msg.contains("ADMIN_RESTART") {
info!("Restart command received - will reconnect");
// Don't exit, just let the loop continue to reconnect
} else {
error!("Session error: {}", e);
}
}
}
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("cancelled") {
info!("Support code was cancelled");
cleanup_on_exit();
show_message_box("Support Session Cancelled", "This support session has been cancelled.");
return Ok(());
}
error!("Connection failed: {}", e);
}
}
if is_support_session {
info!("Support session ended, not reconnecting");
cleanup_on_exit();
return Ok(());
}
info!("Reconnecting in 5 seconds...");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}

View File

@@ -0,0 +1,106 @@
//! SAS Client - Named pipe client for communicating with GuruConnect SAS Service
//!
//! The SAS Service runs as SYSTEM and handles Ctrl+Alt+Del requests.
//! This client sends commands to the service via named pipe.
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{debug, error, info, warn};
const PIPE_NAME: &str = r"\\.\pipe\guruconnect-sas";
const TIMEOUT_MS: u64 = 5000;
/// Request Ctrl+Alt+Del (Secure Attention Sequence) via the SAS service
pub fn request_sas() -> Result<()> {
info!("Requesting SAS via service pipe...");
// Try to connect to the pipe
let mut pipe = match OpenOptions::new()
.read(true)
.write(true)
.open(PIPE_NAME)
{
Ok(p) => p,
Err(e) => {
warn!("Failed to connect to SAS service pipe: {}", e);
return Err(anyhow::anyhow!(
"SAS service not available. Install with: guruconnect-sas-service install"
));
}
};
debug!("Connected to SAS service pipe");
// Send the command
pipe.write_all(b"sas\n")
.context("Failed to send command to SAS service")?;
// Read the response
let mut response = [0u8; 64];
let n = pipe.read(&mut response)
.context("Failed to read response from SAS service")?;
let response_str = String::from_utf8_lossy(&response[..n]);
let response_str = response_str.trim();
debug!("SAS service response: {}", response_str);
match response_str {
"ok" => {
info!("SAS request successful");
Ok(())
}
"error" => {
error!("SAS service reported an error");
Err(anyhow::anyhow!("SAS service failed to send Ctrl+Alt+Del"))
}
_ => {
error!("Unexpected response from SAS service: {}", response_str);
Err(anyhow::anyhow!("Unexpected SAS service response: {}", response_str))
}
}
}
/// Check if the SAS service is available
pub fn is_service_available() -> bool {
// Try to open the pipe
if let Ok(mut pipe) = OpenOptions::new()
.read(true)
.write(true)
.open(PIPE_NAME)
{
// Send a ping command
if pipe.write_all(b"ping\n").is_ok() {
let mut response = [0u8; 64];
if let Ok(n) = pipe.read(&mut response) {
let response_str = String::from_utf8_lossy(&response[..n]);
return response_str.trim() == "pong";
}
}
}
false
}
/// Get information about SAS service status
pub fn get_service_status() -> String {
if is_service_available() {
"SAS service is running and responding".to_string()
} else {
"SAS service is not available".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_check() {
// This test just checks the function runs without panicking
let available = is_service_available();
println!("SAS service available: {}", available);
}
}

View File

@@ -0,0 +1,582 @@
//! Session management for the agent
//!
//! Handles the lifecycle of a remote session including:
//! - Connection to server
//! - Idle mode (heartbeat only, minimal resources)
//! - Active/streaming mode (capture and send frames)
//! - Input event handling
#[cfg(windows)]
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
use crate::capture::{self, Capturer, Display};
use crate::chat::{ChatController, ChatMessage as ChatMsg};
use crate::config::Config;
use crate::encoder::{self, Encoder};
use crate::input::InputController;
/// Show the debug console window (Windows only)
#[cfg(windows)]
fn show_debug_console() {
unsafe {
let hwnd = GetConsoleWindow();
if hwnd.0 == std::ptr::null_mut() {
let _ = AllocConsole();
tracing::info!("Debug console window opened");
} else {
let _ = ShowWindow(hwnd, SW_SHOW);
tracing::info!("Debug console window shown");
}
}
}
#[cfg(not(windows))]
fn show_debug_console() {
// No-op on non-Windows platforms
}
use crate::proto::{Message, message, ChatMessage, AgentStatus, Heartbeat, HeartbeatAck};
use crate::transport::WebSocketTransport;
use crate::tray::{TrayController, TrayAction};
use anyhow::Result;
use std::time::{Duration, Instant};
// Heartbeat interval (30 seconds)
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
// Status report interval (60 seconds)
const STATUS_INTERVAL: Duration = Duration::from_secs(60);
// Update check interval (1 hour)
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(3600);
/// Session manager handles the remote control session
pub struct SessionManager {
config: Config,
transport: Option<WebSocketTransport>,
state: SessionState,
// Lazy-initialized streaming resources
capturer: Option<Box<dyn Capturer>>,
encoder: Option<Box<dyn Encoder>>,
input: Option<InputController>,
// Streaming state
current_viewer_id: Option<String>,
// System info for status reports
hostname: String,
is_elevated: bool,
start_time: Instant,
}
#[derive(Debug, Clone, PartialEq)]
enum SessionState {
Disconnected,
Connecting,
Idle, // Connected but not streaming - minimal resource usage
Streaming, // Actively capturing and sending frames
}
impl SessionManager {
/// Create a new session manager
pub fn new(config: Config, is_elevated: bool) -> Self {
let hostname = config.hostname();
Self {
config,
transport: None,
state: SessionState::Disconnected,
capturer: None,
encoder: None,
input: None,
current_viewer_id: None,
hostname,
is_elevated,
start_time: Instant::now(),
}
}
/// Connect to the server
pub async fn connect(&mut self) -> Result<()> {
self.state = SessionState::Connecting;
let transport = WebSocketTransport::connect(
&self.config.server_url,
&self.config.agent_id,
&self.config.api_key,
Some(&self.hostname),
self.config.support_code.as_deref(),
).await?;
self.transport = Some(transport);
self.state = SessionState::Idle; // Start in idle mode
tracing::info!("Connected to server, entering idle mode");
Ok(())
}
/// Initialize streaming resources (capturer, encoder, input)
fn init_streaming(&mut self) -> Result<()> {
if self.capturer.is_some() {
return Ok(()); // Already initialized
}
tracing::info!("Initializing streaming resources...");
tracing::info!("Capture config: use_dxgi={}, gdi_fallback={}, fps={}",
self.config.capture.use_dxgi, self.config.capture.gdi_fallback, self.config.capture.fps);
// Get primary display with panic protection
tracing::debug!("Enumerating displays...");
let primary_display = match std::panic::catch_unwind(|| capture::primary_display()) {
Ok(result) => result?,
Err(e) => {
tracing::error!("Panic during display enumeration: {:?}", e);
return Err(anyhow::anyhow!("Display enumeration panicked"));
}
};
tracing::info!("Using display: {} ({}x{})",
primary_display.name, primary_display.width, primary_display.height);
// Create capturer with panic protection
// Force GDI mode if DXGI fails or panics
tracing::debug!("Creating capturer (DXGI={})...", self.config.capture.use_dxgi);
let capturer = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
capture::create_capturer(
primary_display.clone(),
self.config.capture.use_dxgi,
self.config.capture.gdi_fallback,
)
})) {
Ok(result) => result?,
Err(e) => {
tracing::error!("Panic during capturer creation: {:?}", e);
// Try GDI-only as last resort
tracing::warn!("Attempting GDI-only capture after DXGI panic...");
capture::create_capturer(primary_display.clone(), false, false)?
}
};
self.capturer = Some(capturer);
tracing::info!("Capturer created successfully");
// Create encoder with panic protection
tracing::debug!("Creating encoder (codec={}, quality={})...",
self.config.encoding.codec, self.config.encoding.quality);
let encoder = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
encoder::create_encoder(
&self.config.encoding.codec,
self.config.encoding.quality,
)
})) {
Ok(result) => result?,
Err(e) => {
tracing::error!("Panic during encoder creation: {:?}", e);
return Err(anyhow::anyhow!("Encoder creation panicked"));
}
};
self.encoder = Some(encoder);
tracing::info!("Encoder created successfully");
// Create input controller with panic protection
tracing::debug!("Creating input controller...");
let input = match std::panic::catch_unwind(InputController::new) {
Ok(result) => result?,
Err(e) => {
tracing::error!("Panic during input controller creation: {:?}", e);
return Err(anyhow::anyhow!("Input controller creation panicked"));
}
};
self.input = Some(input);
tracing::info!("Streaming resources initialized successfully");
Ok(())
}
/// Release streaming resources to save CPU/memory when idle
fn release_streaming(&mut self) {
if self.capturer.is_some() {
tracing::info!("Releasing streaming resources");
self.capturer = None;
self.encoder = None;
self.input = None;
self.current_viewer_id = None;
}
}
/// Get display count for status reports
fn get_display_count(&self) -> i32 {
capture::enumerate_displays().map(|d| d.len() as i32).unwrap_or(1)
}
/// Send agent status to server
async fn send_status(&mut self) -> Result<()> {
let status = AgentStatus {
hostname: self.hostname.clone(),
os_version: std::env::consts::OS.to_string(),
is_elevated: self.is_elevated,
uptime_secs: self.start_time.elapsed().as_secs() as i64,
display_count: self.get_display_count(),
is_streaming: self.state == SessionState::Streaming,
agent_version: crate::build_info::short_version(),
organization: self.config.company.clone().unwrap_or_default(),
site: self.config.site.clone().unwrap_or_default(),
tags: self.config.tags.clone(),
};
let msg = Message {
payload: Some(message::Payload::AgentStatus(status)),
};
if let Some(transport) = self.transport.as_mut() {
transport.send(msg).await?;
}
Ok(())
}
/// Send heartbeat to server
async fn send_heartbeat(&mut self) -> Result<()> {
let heartbeat = Heartbeat {
timestamp: chrono::Utc::now().timestamp_millis(),
};
let msg = Message {
payload: Some(message::Payload::Heartbeat(heartbeat)),
};
if let Some(transport) = self.transport.as_mut() {
transport.send(msg).await?;
}
Ok(())
}
/// Run the session main loop with tray and chat event processing
pub async fn run_with_tray(&mut self, tray: Option<&TrayController>, chat: Option<&ChatController>) -> Result<()> {
if self.transport.is_none() {
anyhow::bail!("Not connected");
}
// Send initial status
self.send_status().await?;
// Timing for heartbeat and status
let mut last_heartbeat = Instant::now();
let mut last_status = Instant::now();
let mut last_frame_time = Instant::now();
let mut last_update_check = Instant::now();
let frame_interval = Duration::from_millis(1000 / self.config.capture.fps as u64);
// 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 => {
tracing::info!("User requested details (not yet implemented)");
}
TrayAction::ShowDebugWindow => {
show_debug_console();
}
}
}
if t.exit_requested() {
tracing::info!("Exit requested via tray");
return Err(anyhow::anyhow!("USER_EXIT: Exit requested by user"));
}
}
// Process incoming messages
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 {
// Handle chat messages specially
if let Some(message::Payload::ChatMessage(chat_msg)) = &msg.payload {
if let Some(c) = chat {
c.add_message(ChatMsg {
id: chat_msg.id.clone(),
sender: chat_msg.sender.clone(),
content: chat_msg.content.clone(),
timestamp: chat_msg.timestamp,
});
}
continue;
}
// Handle control messages that affect state
if let Some(ref payload) = msg.payload {
match payload {
message::Payload::StartStream(start) => {
tracing::info!("StartStream received from viewer: {}", start.viewer_id);
if let Err(e) = self.init_streaming() {
tracing::error!("Failed to init streaming: {}", e);
} else {
self.state = SessionState::Streaming;
self.current_viewer_id = Some(start.viewer_id.clone());
tracing::info!("Now streaming to viewer {}", start.viewer_id);
}
continue;
}
message::Payload::StopStream(stop) => {
tracing::info!("StopStream received for viewer: {}", stop.viewer_id);
// Only stop if it matches current viewer
if self.current_viewer_id.as_ref() == Some(&stop.viewer_id) {
self.release_streaming();
self.state = SessionState::Idle;
tracing::info!("Stopped streaming, returning to idle mode");
}
continue;
}
message::Payload::Heartbeat(hb) => {
// Respond to server heartbeat with ack
let ack = HeartbeatAck {
client_timestamp: hb.timestamp,
server_timestamp: chrono::Utc::now().timestamp_millis(),
};
let ack_msg = Message {
payload: Some(message::Payload::HeartbeatAck(ack)),
};
if let Some(transport) = self.transport.as_mut() {
let _ = transport.send(ack_msg).await;
}
continue;
}
_ => {}
}
}
// Handle other messages (input events, disconnect, etc.)
self.handle_message(msg).await?;
}
// Check for outgoing chat messages
if let Some(c) = chat {
if let Some(outgoing) = c.poll_outgoing() {
let chat_proto = ChatMessage {
id: outgoing.id,
sender: "client".to_string(),
content: outgoing.content,
timestamp: outgoing.timestamp,
};
let msg = Message {
payload: Some(message::Payload::ChatMessage(chat_proto)),
};
let transport = self.transport.as_mut().unwrap();
transport.send(msg).await?;
}
}
// State-specific behavior
match self.state {
SessionState::Idle => {
// In idle mode, just send heartbeats and status periodically
if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
last_heartbeat = Instant::now();
if let Err(e) = self.send_heartbeat().await {
tracing::warn!("Failed to send heartbeat: {}", e);
}
}
if last_status.elapsed() >= STATUS_INTERVAL {
last_status = Instant::now();
if let Err(e) = self.send_status().await {
tracing::warn!("Failed to send status: {}", e);
}
}
// Periodic update check (only for persistent agents, not support sessions)
if self.config.support_code.is_none() && last_update_check.elapsed() >= UPDATE_CHECK_INTERVAL {
last_update_check = Instant::now();
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
match crate::update::check_for_update(&server_url).await {
Ok(Some(version_info)) => {
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
if let Err(e) = crate::update::perform_update(&version_info).await {
tracing::error!("Auto-update failed: {}", e);
}
}
Ok(None) => {
tracing::debug!("No update available");
}
Err(e) => {
tracing::debug!("Update check failed: {}", e);
}
}
}
// Longer sleep in idle mode to reduce CPU usage
tokio::time::sleep(Duration::from_millis(100)).await;
}
SessionState::Streaming => {
// In streaming mode, capture and send frames
if last_frame_time.elapsed() >= frame_interval {
last_frame_time = Instant::now();
if let (Some(capturer), Some(encoder)) =
(self.capturer.as_mut(), self.encoder.as_mut())
{
if let Ok(Some(frame)) = capturer.capture() {
if let Ok(encoded) = encoder.encode(&frame) {
if encoded.size > 0 {
let msg = Message {
payload: Some(message::Payload::VideoFrame(encoded.frame)),
};
let transport = self.transport.as_mut().unwrap();
if let Err(e) = transport.send(msg).await {
tracing::warn!("Failed to send frame: {}", e);
}
}
}
}
}
}
// Short sleep in streaming mode
tokio::time::sleep(Duration::from_millis(1)).await;
}
_ => {
// Disconnected or connecting - shouldn't be in main loop
tokio::time::sleep(Duration::from_millis(100)).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.release_streaming();
self.state = SessionState::Disconnected;
Ok(())
}
/// Handle incoming message from server
async fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg.payload {
Some(message::Payload::MouseEvent(mouse)) => {
if let Some(input) = self.input.as_mut() {
use crate::proto::MouseEventType;
use crate::input::MouseButton;
match MouseEventType::try_from(mouse.event_type).unwrap_or(MouseEventType::MouseMove) {
MouseEventType::MouseMove => {
input.mouse_move(mouse.x, mouse.y)?;
}
MouseEventType::MouseDown => {
input.mouse_move(mouse.x, mouse.y)?;
if let Some(ref buttons) = mouse.buttons {
if buttons.left { input.mouse_click(MouseButton::Left, true)?; }
if buttons.right { input.mouse_click(MouseButton::Right, true)?; }
if buttons.middle { input.mouse_click(MouseButton::Middle, true)?; }
}
}
MouseEventType::MouseUp => {
if let Some(ref buttons) = mouse.buttons {
if buttons.left { input.mouse_click(MouseButton::Left, false)?; }
if buttons.right { input.mouse_click(MouseButton::Right, false)?; }
if buttons.middle { input.mouse_click(MouseButton::Middle, false)?; }
}
}
MouseEventType::MouseWheel => {
input.mouse_scroll(mouse.wheel_delta_x, mouse.wheel_delta_y)?;
}
}
}
}
Some(message::Payload::KeyEvent(key)) => {
if let Some(input) = self.input.as_mut() {
input.key_event(key.vk_code as u16, key.down)?;
}
}
Some(message::Payload::SpecialKey(special)) => {
if let Some(input) = self.input.as_mut() {
use crate::proto::SpecialKey;
match SpecialKey::try_from(special.key).ok() {
Some(SpecialKey::CtrlAltDel) => {
input.send_ctrl_alt_del()?;
}
_ => {}
}
}
}
Some(message::Payload::AdminCommand(cmd)) => {
use crate::proto::AdminCommandType;
tracing::info!("Admin command received: {:?} - {}", cmd.command, cmd.reason);
match AdminCommandType::try_from(cmd.command).ok() {
Some(AdminCommandType::AdminUninstall) => {
tracing::warn!("Uninstall command received from server");
// Return special error to trigger uninstall in main loop
return Err(anyhow::anyhow!("ADMIN_UNINSTALL: {}", cmd.reason));
}
Some(AdminCommandType::AdminRestart) => {
tracing::info!("Restart command received from server");
// For now, just disconnect - the auto-restart logic will handle it
return Err(anyhow::anyhow!("ADMIN_RESTART: {}", cmd.reason));
}
Some(AdminCommandType::AdminUpdate) => {
tracing::info!("Update command received from server: {}", cmd.reason);
// Trigger update check and perform update if available
// The server URL is derived from the config
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
match crate::update::check_for_update(&server_url).await {
Ok(Some(version_info)) => {
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
if let Err(e) = crate::update::perform_update(&version_info).await {
tracing::error!("Update failed: {}", e);
}
// If we get here, the update failed (perform_update exits on success)
}
Ok(None) => {
tracing::info!("Already running latest version");
}
Err(e) => {
tracing::error!("Failed to check for updates: {}", e);
}
}
}
None => {
tracing::warn!("Unknown admin command: {}", cmd.command);
}
}
}
Some(message::Payload::Disconnect(disc)) => {
tracing::info!("Disconnect requested: {}", disc.reason);
if disc.reason.contains("cancelled") {
return Err(anyhow::anyhow!("SESSION_CANCELLED: {}", disc.reason));
}
if disc.reason.contains("administrator") || disc.reason.contains("Disconnected") {
return Err(anyhow::anyhow!("ADMIN_DISCONNECT: {}", disc.reason));
}
return Err(anyhow::anyhow!("Disconnect: {}", disc.reason));
}
_ => {
// Ignore unknown messages
}
}
Ok(())
}
}

View File

@@ -0,0 +1,298 @@
//! Startup persistence for the agent
//!
//! Handles adding/removing the agent from Windows startup.
use anyhow::Result;
use tracing::{info, warn, error};
#[cfg(windows)]
use windows::Win32::System::Registry::{
RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, RegCloseKey,
HKEY_CURRENT_USER, KEY_WRITE, REG_SZ,
};
#[cfg(windows)]
use windows::core::PCWSTR;
const STARTUP_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
const STARTUP_VALUE_NAME: &str = "GuruConnect";
/// Add the current executable to Windows startup
#[cfg(windows)]
pub fn add_to_startup() -> Result<()> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
// Get the path to the current executable
let exe_path = std::env::current_exe()?;
let exe_path_str = exe_path.to_string_lossy();
info!("Adding to startup: {}", exe_path_str);
// Convert strings to wide strings
let key_path: Vec<u16> = OsStr::new(STARTUP_KEY)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = OsStr::new(STARTUP_VALUE_NAME)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_data: Vec<u16> = OsStr::new(&*exe_path_str)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut hkey = windows::Win32::Foundation::HANDLE::default();
// Open the Run key
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR(key_path.as_ptr()),
0,
KEY_WRITE,
&mut hkey as *mut _ as *mut _,
);
if result.is_err() {
anyhow::bail!("Failed to open registry key: {:?}", result);
}
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
// Set the value
let data_bytes = std::slice::from_raw_parts(
value_data.as_ptr() as *const u8,
value_data.len() * 2,
);
let set_result = RegSetValueExW(
hkey_raw,
PCWSTR(value_name.as_ptr()),
0,
REG_SZ,
Some(data_bytes),
);
let _ = RegCloseKey(hkey_raw);
if set_result.is_err() {
anyhow::bail!("Failed to set registry value: {:?}", set_result);
}
}
info!("Successfully added to startup");
Ok(())
}
/// Remove the agent from Windows startup
#[cfg(windows)]
pub fn remove_from_startup() -> Result<()> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
info!("Removing from startup");
let key_path: Vec<u16> = OsStr::new(STARTUP_KEY)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = OsStr::new(STARTUP_VALUE_NAME)
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut hkey = windows::Win32::Foundation::HANDLE::default();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR(key_path.as_ptr()),
0,
KEY_WRITE,
&mut hkey as *mut _ as *mut _,
);
if result.is_err() {
warn!("Failed to open registry key for removal: {:?}", result);
return Ok(()); // Not an error if key doesn't exist
}
let hkey_raw = std::mem::transmute::<_, windows::Win32::System::Registry::HKEY>(hkey);
let delete_result = RegDeleteValueW(hkey_raw, PCWSTR(value_name.as_ptr()));
let _ = RegCloseKey(hkey_raw);
if delete_result.is_err() {
warn!("Registry value may not exist: {:?}", delete_result);
} else {
info!("Successfully removed from startup");
}
}
Ok(())
}
/// Full uninstall: remove from startup and delete the executable
#[cfg(windows)]
pub fn uninstall() -> Result<()> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
info!("Uninstalling agent");
// First remove from startup
let _ = remove_from_startup();
// Get the path to the current executable
let exe_path = std::env::current_exe()?;
let exe_path_str = exe_path.to_string_lossy();
info!("Scheduling deletion of: {}", exe_path_str);
// Convert path to wide string
let exe_wide: Vec<u16> = OsStr::new(&*exe_path_str)
.encode_wide()
.chain(std::iter::once(0))
.collect();
// Schedule the file for deletion on next reboot
// This is necessary because the executable is currently running
unsafe {
let result = MoveFileExW(
PCWSTR(exe_wide.as_ptr()),
PCWSTR::null(),
MOVEFILE_DELAY_UNTIL_REBOOT,
);
if result.is_err() {
warn!("Failed to schedule file deletion: {:?}. File may need manual removal.", result);
} else {
info!("Executable scheduled for deletion on reboot");
}
}
Ok(())
}
/// Install the SAS service if the binary is available
/// This allows the agent to send Ctrl+Alt+Del even without SYSTEM privileges
#[cfg(windows)]
pub fn install_sas_service() -> Result<()> {
info!("Attempting to install SAS service...");
// Check if the SAS service binary exists alongside the agent
let exe_path = std::env::current_exe()?;
let exe_dir = exe_path.parent().ok_or_else(|| anyhow::anyhow!("No parent directory"))?;
let sas_binary = exe_dir.join("guruconnect-sas-service.exe");
if !sas_binary.exists() {
// Also check in Program Files
let program_files = std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe");
if !program_files.exists() {
warn!("SAS service binary not found");
return Ok(());
}
}
// Run the install command
let sas_path = if sas_binary.exists() {
sas_binary
} else {
std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe")
};
let output = std::process::Command::new(&sas_path)
.arg("install")
.output();
match output {
Ok(result) => {
if result.status.success() {
info!("SAS service installed successfully");
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
warn!("SAS service install failed: {}", stderr);
}
}
Err(e) => {
warn!("Failed to run SAS service installer: {}", e);
}
}
Ok(())
}
/// Uninstall the SAS service
#[cfg(windows)]
pub fn uninstall_sas_service() -> Result<()> {
info!("Attempting to uninstall SAS service...");
// Try to find and run the uninstall command
let paths = [
std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.join("guruconnect-sas-service.exe"))),
Some(std::path::PathBuf::from(r"C:\Program Files\GuruConnect\guruconnect-sas-service.exe")),
];
for path_opt in paths.iter() {
if let Some(ref path) = path_opt {
if path.exists() {
let output = std::process::Command::new(path)
.arg("uninstall")
.output();
if let Ok(result) = output {
if result.status.success() {
info!("SAS service uninstalled successfully");
return Ok(());
}
}
}
}
}
warn!("SAS service binary not found for uninstall");
Ok(())
}
/// Check if the SAS service is installed and running
#[cfg(windows)]
pub fn check_sas_service() -> bool {
use crate::sas_client;
sas_client::is_service_available()
}
#[cfg(not(windows))]
pub fn add_to_startup() -> Result<()> {
warn!("Startup persistence not implemented for this platform");
Ok(())
}
#[cfg(not(windows))]
pub fn remove_from_startup() -> Result<()> {
Ok(())
}
#[cfg(not(windows))]
pub fn uninstall() -> Result<()> {
warn!("Uninstall not implemented for this platform");
Ok(())
}
#[cfg(not(windows))]
pub fn install_sas_service() -> Result<()> {
warn!("SAS service only available on Windows");
Ok(())
}
#[cfg(not(windows))]
pub fn uninstall_sas_service() -> Result<()> {
Ok(())
}
#[cfg(not(windows))]
pub fn check_sas_service() -> bool {
false
}

View File

@@ -0,0 +1,5 @@
//! WebSocket transport for agent-server communication
mod websocket;
pub use websocket::WebSocketTransport;

View File

@@ -0,0 +1,206 @@
//! WebSocket client transport
//!
//! Handles WebSocket connection to the GuruConnect server with:
//! - TLS encryption
//! - Automatic reconnection
//! - Protobuf message serialization
use crate::proto::Message;
use anyhow::{Context, Result};
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use prost::Message as ProstMessage;
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio_tungstenite::{
connect_async, tungstenite::protocol::Message as WsMessage, MaybeTlsStream, WebSocketStream,
};
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
/// WebSocket transport for server communication
pub struct WebSocketTransport {
stream: Arc<Mutex<WsStream>>,
incoming: VecDeque<Message>,
connected: bool,
}
impl WebSocketTransport {
/// Connect to the server
pub async fn connect(
url: &str,
agent_id: &str,
api_key: &str,
hostname: Option<&str>,
support_code: Option<&str>,
) -> Result<Self> {
// Build query parameters
let mut params = format!("agent_id={}&api_key={}", agent_id, api_key);
if let Some(hostname) = hostname {
params.push_str(&format!("&hostname={}", urlencoding::encode(hostname)));
}
if let Some(code) = support_code {
params.push_str(&format!("&support_code={}", code));
}
// Append parameters to URL
let url_with_params = if url.contains('?') {
format!("{}&{}", url, params)
} else {
format!("{}?{}", url, params)
};
tracing::info!("Connecting to {} as agent {}", url, agent_id);
if let Some(code) = support_code {
tracing::info!("Using support code: {}", code);
}
let (ws_stream, response) = connect_async(&url_with_params)
.await
.context("Failed to connect to WebSocket server")?;
tracing::info!("Connected, status: {}", response.status());
Ok(Self {
stream: Arc::new(Mutex::new(ws_stream)),
incoming: VecDeque::new(),
connected: true,
})
}
/// Send a protobuf message
pub async fn send(&mut self, msg: Message) -> Result<()> {
let mut stream = self.stream.lock().await;
// Serialize to protobuf binary
let mut buf = Vec::with_capacity(msg.encoded_len());
msg.encode(&mut buf)?;
// Send as binary WebSocket message
stream
.send(WsMessage::Binary(buf.into()))
.await
.context("Failed to send message")?;
Ok(())
}
/// Try to receive a message (non-blocking)
pub fn try_recv(&mut self) -> Result<Option<Message>> {
// Return buffered message if available
if let Some(msg) = self.incoming.pop_front() {
return Ok(Some(msg));
}
// Try to receive more messages
let stream = self.stream.clone();
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let mut stream = stream.lock().await;
// Use try_next for non-blocking receive
match tokio::time::timeout(
std::time::Duration::from_millis(1),
stream.next(),
)
.await
{
Ok(Some(Ok(ws_msg))) => Ok(Some(ws_msg)),
Ok(Some(Err(e))) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
Ok(None) => {
// Connection closed
Ok(None)
}
Err(_) => {
// Timeout - no message available
Ok(None)
}
}
})
});
match result? {
Some(ws_msg) => {
if let Some(msg) = self.parse_message(ws_msg)? {
Ok(Some(msg))
} else {
Ok(None)
}
}
None => Ok(None),
}
}
/// Receive a message (blocking)
pub async fn recv(&mut self) -> Result<Option<Message>> {
// Return buffered message if available
if let Some(msg) = self.incoming.pop_front() {
return Ok(Some(msg));
}
let result = {
let mut stream = self.stream.lock().await;
stream.next().await
};
match result {
Some(Ok(ws_msg)) => self.parse_message(ws_msg),
Some(Err(e)) => {
self.connected = false;
Err(anyhow::anyhow!("WebSocket error: {}", e))
}
None => {
self.connected = false;
Ok(None)
}
}
}
/// Parse a WebSocket message into a protobuf message
fn parse_message(&mut self, ws_msg: WsMessage) -> Result<Option<Message>> {
match ws_msg {
WsMessage::Binary(data) => {
let msg = Message::decode(Bytes::from(data))
.context("Failed to decode protobuf message")?;
Ok(Some(msg))
}
WsMessage::Ping(data) => {
// Pong is sent automatically by tungstenite
tracing::trace!("Received ping");
Ok(None)
}
WsMessage::Pong(_) => {
tracing::trace!("Received pong");
Ok(None)
}
WsMessage::Close(frame) => {
tracing::info!("Connection closed: {:?}", frame);
self.connected = false;
Ok(None)
}
WsMessage::Text(text) => {
// We expect binary protobuf, but log text messages
tracing::warn!("Received unexpected text message: {}", text);
Ok(None)
}
_ => Ok(None),
}
}
/// Check if connected
pub fn is_connected(&self) -> bool {
self.connected
}
/// Close the connection
pub async fn close(&mut self) -> Result<()> {
let mut stream = self.stream.lock().await;
stream.close(None).await?;
self.connected = false;
Ok(())
}
}

View File

@@ -0,0 +1,197 @@
//! 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());
}
}

View File

@@ -0,0 +1,318 @@
//! Auto-update module for GuruConnect agent
//!
//! Handles checking for updates, downloading new versions, and performing
//! in-place binary replacement with restart.
use anyhow::{anyhow, Result};
use sha2::{Sha256, Digest};
use std::path::PathBuf;
use tracing::{info, warn, error};
use crate::build_info;
/// Version information from the server
#[derive(Debug, Clone, serde::Deserialize)]
pub struct VersionInfo {
pub latest_version: String,
pub download_url: String,
pub checksum_sha256: String,
pub is_mandatory: bool,
pub release_notes: Option<String>,
}
/// Update state tracking
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UpdateState {
Idle,
Checking,
Downloading,
Verifying,
Installing,
Restarting,
Failed,
}
/// Check if an update is available
pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInfo>> {
let url = format!("{}/api/version", server_base_url.trim_end_matches('/'));
info!("Checking for updates at {}", url);
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // For self-signed certs in dev
.build()?;
let response = client
.get(&url)
.timeout(std::time::Duration::from_secs(30))
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
info!("No stable release available on server");
return Ok(None);
}
if !response.status().is_success() {
return Err(anyhow!("Version check failed: HTTP {}", response.status()));
}
let version_info: VersionInfo = response.json().await?;
// Compare versions
let current = build_info::VERSION;
if is_newer_version(&version_info.latest_version, current) {
info!(
"Update available: {} -> {} (mandatory: {})",
current, version_info.latest_version, version_info.is_mandatory
);
Ok(Some(version_info))
} else {
info!("Already running latest version: {}", current);
Ok(None)
}
}
/// Simple semantic version comparison
/// Returns true if `available` is newer than `current`
fn is_newer_version(available: &str, current: &str) -> bool {
// Strip any git hash suffix (e.g., "0.1.0-abc123" -> "0.1.0")
let available_clean = available.split('-').next().unwrap_or(available);
let current_clean = current.split('-').next().unwrap_or(current);
let parse_version = |s: &str| -> Vec<u32> {
s.split('.')
.filter_map(|p| p.parse().ok())
.collect()
};
let av = parse_version(available_clean);
let cv = parse_version(current_clean);
// Compare component by component
for i in 0..av.len().max(cv.len()) {
let a = av.get(i).copied().unwrap_or(0);
let c = cv.get(i).copied().unwrap_or(0);
if a > c {
return true;
}
if a < c {
return false;
}
}
false
}
/// Download update to temporary file
pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
info!("Downloading update from {}", version_info.download_url);
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()?;
let response = client
.get(&version_info.download_url)
.timeout(std::time::Duration::from_secs(300)) // 5 minutes for large files
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow!("Download failed: HTTP {}", response.status()));
}
// Get temp directory
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join("guruconnect-update.exe");
// Download to file
let bytes = response.bytes().await?;
std::fs::write(&temp_path, &bytes)?;
info!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
Ok(temp_path)
}
/// Verify downloaded file checksum
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
info!("Verifying checksum...");
let contents = std::fs::read(file_path)?;
let mut hasher = Sha256::new();
hasher.update(&contents);
let result = hasher.finalize();
let computed = format!("{:x}", result);
let matches = computed.eq_ignore_ascii_case(expected_sha256);
if matches {
info!("Checksum verified: {}", computed);
} else {
error!("Checksum mismatch! Expected: {}, Got: {}", expected_sha256, computed);
}
Ok(matches)
}
/// Perform the actual update installation
/// This renames the current executable and copies the new one in place
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
info!("Installing update...");
// Get current executable path
let current_exe = std::env::current_exe()?;
let exe_dir = current_exe.parent()
.ok_or_else(|| anyhow!("Cannot get executable directory"))?;
// Create paths for backup and new executable
let backup_path = exe_dir.join("guruconnect.exe.old");
// Delete any existing backup
if backup_path.exists() {
if let Err(e) = std::fs::remove_file(&backup_path) {
warn!("Could not remove old backup: {}", e);
}
}
// Rename current executable to .old (this works even while running)
info!("Renaming current exe to backup: {:?}", backup_path);
std::fs::rename(&current_exe, &backup_path)?;
// Copy new executable to original location
info!("Copying new exe to: {:?}", current_exe);
std::fs::copy(temp_path, &current_exe)?;
// Clean up temp file
let _ = std::fs::remove_file(temp_path);
info!("Update installed successfully");
Ok(current_exe)
}
/// Spawn new process and exit current one
pub fn restart_with_new_version(exe_path: &PathBuf, args: &[String]) -> Result<()> {
info!("Restarting with new version...");
// Build command with --post-update flag
let mut cmd_args = vec!["--post-update".to_string()];
cmd_args.extend(args.iter().cloned());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
std::process::Command::new(exe_path)
.args(&cmd_args)
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn()?;
}
#[cfg(not(windows))]
{
std::process::Command::new(exe_path)
.args(&cmd_args)
.spawn()?;
}
info!("New process spawned, exiting current process");
Ok(())
}
/// Clean up old executable after successful update
pub fn cleanup_post_update() {
let current_exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
warn!("Could not get current exe path for cleanup: {}", e);
return;
}
};
let exe_dir = match current_exe.parent() {
Some(d) => d,
None => {
warn!("Could not get executable directory for cleanup");
return;
}
};
let backup_path = exe_dir.join("guruconnect.exe.old");
if backup_path.exists() {
info!("Cleaning up old executable: {:?}", backup_path);
match std::fs::remove_file(&backup_path) {
Ok(_) => info!("Old executable removed successfully"),
Err(e) => {
warn!("Could not remove old executable (may be in use): {}", e);
// On Windows, we might need to schedule deletion on reboot
#[cfg(windows)]
schedule_delete_on_reboot(&backup_path);
}
}
}
}
/// Schedule file deletion on reboot (Windows)
#[cfg(windows)]
fn schedule_delete_on_reboot(path: &PathBuf) {
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
use windows::core::PCWSTR;
let path_wide: Vec<u16> = path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let result = MoveFileExW(
PCWSTR(path_wide.as_ptr()),
PCWSTR::null(),
MOVEFILE_DELAY_UNTIL_REBOOT,
);
if result.is_ok() {
info!("Scheduled {:?} for deletion on reboot", path);
} else {
warn!("Failed to schedule {:?} for deletion on reboot", path);
}
}
}
/// Perform complete update process
pub async fn perform_update(version_info: &VersionInfo) -> Result<()> {
// Download
let temp_path = download_update(version_info).await?;
// Verify
if !verify_checksum(&temp_path, &version_info.checksum_sha256)? {
let _ = std::fs::remove_file(&temp_path);
return Err(anyhow!("Update verification failed: checksum mismatch"));
}
// Install
let exe_path = install_update(&temp_path)?;
// Restart
// Get current args (without the current executable name)
let args: Vec<String> = std::env::args().skip(1).collect();
restart_with_new_version(&exe_path, &args)?;
// Exit current process
std::process::exit(0);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_comparison() {
assert!(is_newer_version("0.2.0", "0.1.0"));
assert!(is_newer_version("1.0.0", "0.9.9"));
assert!(is_newer_version("0.1.1", "0.1.0"));
assert!(!is_newer_version("0.1.0", "0.1.0"));
assert!(!is_newer_version("0.1.0", "0.2.0"));
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
}
}

View File

@@ -0,0 +1,173 @@
//! Low-level keyboard hook for capturing all keys including Win key
use super::InputEvent;
#[cfg(windows)]
use crate::proto;
use anyhow::Result;
use tokio::sync::mpsc;
#[cfg(windows)]
use tracing::trace;
#[cfg(windows)]
use windows::{
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, GetMessageW, PeekMessageW, SetWindowsHookExW,
TranslateMessage, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE,
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
},
};
#[cfg(windows)]
use std::sync::OnceLock;
#[cfg(windows)]
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
#[cfg(windows)]
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
/// Virtual key codes for special keys
#[cfg(windows)]
mod vk {
pub const VK_LWIN: u32 = 0x5B;
pub const VK_RWIN: u32 = 0x5C;
pub const VK_APPS: u32 = 0x5D;
pub const VK_LSHIFT: u32 = 0xA0;
pub const VK_RSHIFT: u32 = 0xA1;
pub const VK_LCONTROL: u32 = 0xA2;
pub const VK_RCONTROL: u32 = 0xA3;
pub const VK_LMENU: u32 = 0xA4; // Left Alt
pub const VK_RMENU: u32 = 0xA5; // Right Alt
pub const VK_TAB: u32 = 0x09;
pub const VK_ESCAPE: u32 = 0x1B;
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
}
#[cfg(windows)]
pub struct KeyboardHook {
_hook: HHOOK,
}
#[cfg(windows)]
impl KeyboardHook {
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
// Store the sender globally for the hook callback
INPUT_TX.set(input_tx).map_err(|_| anyhow::anyhow!("Input TX already set"))?;
unsafe {
let hook = SetWindowsHookExW(
WH_KEYBOARD_LL,
Some(keyboard_hook_proc),
None,
0,
)?;
HOOK_HANDLE = hook;
Ok(Self { _hook: hook })
}
}
}
#[cfg(windows)]
impl Drop for KeyboardHook {
fn drop(&mut self) {
unsafe {
if !HOOK_HANDLE.0.is_null() {
let _ = UnhookWindowsHookEx(HOOK_HANDLE);
HOOK_HANDLE = HHOOK(std::ptr::null_mut());
}
}
}
}
#[cfg(windows)]
unsafe extern "system" fn keyboard_hook_proc(
code: i32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
if code >= 0 {
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
let vk_code = kb_struct.vkCode;
let scan_code = kb_struct.scanCode;
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
if is_down || is_up {
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
let should_intercept = matches!(
vk_code,
vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS
);
// Send the key event to the remote
if let Some(tx) = INPUT_TX.get() {
let event = proto::KeyEvent {
down: is_down,
key_type: proto::KeyEventType::KeyVk as i32,
vk_code,
scan_code,
unicode: String::new(),
modifiers: Some(get_current_modifiers()),
};
let _ = tx.try_send(InputEvent::Key(event));
trace!("Key hook: vk={:#x} scan={} down={}", vk_code, scan_code, is_down);
}
// For Win key, consume the event so it doesn't open Start menu locally
if should_intercept {
return LRESULT(1);
}
}
}
CallNextHookEx(HOOK_HANDLE, code, wparam, lparam)
}
#[cfg(windows)]
fn get_current_modifiers() -> proto::Modifiers {
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
unsafe {
proto::Modifiers {
ctrl: GetAsyncKeyState(0x11) < 0, // VK_CONTROL
alt: GetAsyncKeyState(0x12) < 0, // VK_MENU
shift: GetAsyncKeyState(0x10) < 0, // VK_SHIFT
meta: GetAsyncKeyState(0x5B) < 0 || GetAsyncKeyState(0x5C) < 0, // VK_LWIN/RWIN
caps_lock: GetAsyncKeyState(0x14) & 1 != 0, // VK_CAPITAL
num_lock: GetAsyncKeyState(0x90) & 1 != 0, // VK_NUMLOCK
}
}
}
/// Pump Windows message queue (required for hooks to work)
#[cfg(windows)]
pub fn pump_messages() {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
// Non-Windows stubs
#[cfg(not(windows))]
#[allow(dead_code)]
pub struct KeyboardHook;
#[cfg(not(windows))]
#[allow(dead_code)]
impl KeyboardHook {
pub fn new(_input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
Ok(Self)
}
}
#[cfg(not(windows))]
#[allow(dead_code)]
pub fn pump_messages() {}

View File

@@ -0,0 +1,121 @@
//! Viewer module - Native remote desktop viewer with full keyboard capture
//!
//! This module provides the viewer functionality for connecting to remote
//! GuruConnect sessions with low-level keyboard hooks for Win key capture.
mod input;
mod render;
mod transport;
use crate::proto;
use anyhow::Result;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tracing::{info, error, warn};
#[derive(Debug, Clone)]
pub enum ViewerEvent {
Connected,
Disconnected(String),
Frame(render::FrameData),
CursorPosition(i32, i32, bool),
CursorShape(proto::CursorShape),
}
#[derive(Debug, Clone)]
pub enum InputEvent {
Mouse(proto::MouseEvent),
Key(proto::KeyEvent),
SpecialKey(proto::SpecialKeyEvent),
}
/// Run the viewer to connect to a remote session
pub async fn run(server_url: &str, session_id: &str, api_key: &str) -> Result<()> {
info!("GuruConnect Viewer starting");
info!("Server: {}", server_url);
info!("Session: {}", session_id);
// Create channels for communication between components
let (viewer_tx, viewer_rx) = mpsc::channel::<ViewerEvent>(100);
let (input_tx, input_rx) = mpsc::channel::<InputEvent>(100);
// Connect to server
let ws_url = format!("{}?session_id={}", server_url, session_id);
info!("Connecting to {}", ws_url);
let (ws_sender, mut ws_receiver) = transport::connect(&ws_url, api_key).await?;
let ws_sender = Arc::new(Mutex::new(ws_sender));
info!("Connected to server");
let _ = viewer_tx.send(ViewerEvent::Connected).await;
// Clone sender for input forwarding
let ws_sender_input = ws_sender.clone();
// Spawn task to forward input events to server
let mut input_rx = input_rx;
let input_task = tokio::spawn(async move {
while let Some(event) = input_rx.recv().await {
let msg = match event {
InputEvent::Mouse(m) => proto::Message {
payload: Some(proto::message::Payload::MouseEvent(m)),
},
InputEvent::Key(k) => proto::Message {
payload: Some(proto::message::Payload::KeyEvent(k)),
},
InputEvent::SpecialKey(s) => proto::Message {
payload: Some(proto::message::Payload::SpecialKey(s)),
},
};
if let Err(e) = transport::send_message(&ws_sender_input, &msg).await {
error!("Failed to send input: {}", e);
break;
}
}
});
// Spawn task to receive messages from server
let viewer_tx_recv = viewer_tx.clone();
let receive_task = tokio::spawn(async move {
while let Some(msg) = ws_receiver.recv().await {
match msg.payload {
Some(proto::message::Payload::VideoFrame(frame)) => {
if let Some(proto::video_frame::Encoding::Raw(raw)) = frame.encoding {
let frame_data = render::FrameData {
width: raw.width as u32,
height: raw.height as u32,
data: raw.data,
compressed: raw.compressed,
is_keyframe: raw.is_keyframe,
};
let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await;
}
}
Some(proto::message::Payload::CursorPosition(pos)) => {
let _ = viewer_tx_recv.send(ViewerEvent::CursorPosition(
pos.x, pos.y, pos.visible
)).await;
}
Some(proto::message::Payload::CursorShape(shape)) => {
let _ = viewer_tx_recv.send(ViewerEvent::CursorShape(shape)).await;
}
Some(proto::message::Payload::Disconnect(d)) => {
warn!("Server disconnected: {}", d.reason);
let _ = viewer_tx_recv.send(ViewerEvent::Disconnected(d.reason)).await;
break;
}
_ => {}
}
}
});
// Run the window (this blocks until window closes)
render::run_window(viewer_rx, input_tx).await?;
// Cleanup
input_task.abort();
receive_task.abort();
Ok(())
}

View File

@@ -0,0 +1,508 @@
//! Window rendering and frame display
use super::{ViewerEvent, InputEvent};
use crate::proto;
#[cfg(windows)]
use super::input;
use anyhow::Result;
use std::num::NonZeroU32;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use winit::{
application::ApplicationHandler,
dpi::LogicalSize,
event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey},
window::{Window, WindowId},
};
/// Frame data received from server
#[derive(Debug, Clone)]
pub struct FrameData {
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
pub compressed: bool,
pub is_keyframe: bool,
}
struct ViewerApp {
window: Option<Arc<Window>>,
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
frame_buffer: Vec<u32>,
frame_width: u32,
frame_height: u32,
viewer_rx: mpsc::Receiver<ViewerEvent>,
input_tx: mpsc::Sender<InputEvent>,
mouse_x: i32,
mouse_y: i32,
#[cfg(windows)]
keyboard_hook: Option<input::KeyboardHook>,
}
impl ViewerApp {
fn new(
viewer_rx: mpsc::Receiver<ViewerEvent>,
input_tx: mpsc::Sender<InputEvent>,
) -> Self {
Self {
window: None,
surface: None,
frame_buffer: Vec::new(),
frame_width: 0,
frame_height: 0,
viewer_rx,
input_tx,
mouse_x: 0,
mouse_y: 0,
#[cfg(windows)]
keyboard_hook: None,
}
}
fn process_frame(&mut self, frame: FrameData) {
let data = if frame.compressed {
// Decompress zstd
match zstd::decode_all(frame.data.as_slice()) {
Ok(decompressed) => decompressed,
Err(e) => {
error!("Failed to decompress frame: {}", e);
return;
}
}
} else {
frame.data
};
// Convert BGRA to ARGB (softbuffer expects 0RGB format on little-endian)
let pixel_count = (frame.width * frame.height) as usize;
if data.len() < pixel_count * 4 {
error!("Frame data too small: {} < {}", data.len(), pixel_count * 4);
return;
}
// Resize frame buffer if needed
if self.frame_width != frame.width || self.frame_height != frame.height {
self.frame_width = frame.width;
self.frame_height = frame.height;
self.frame_buffer.resize(pixel_count, 0);
// Resize window to match frame
if let Some(window) = &self.window {
let _ = window.request_inner_size(LogicalSize::new(frame.width, frame.height));
}
}
// Convert BGRA to 0RGB (ignore alpha, swap B and R)
for i in 0..pixel_count {
let offset = i * 4;
let b = data[offset] as u32;
let g = data[offset + 1] as u32;
let r = data[offset + 2] as u32;
// 0RGB format: 0x00RRGGBB
self.frame_buffer[i] = (r << 16) | (g << 8) | b;
}
// Request redraw
if let Some(window) = &self.window {
window.request_redraw();
}
}
fn render(&mut self) {
let Some(surface) = &mut self.surface else { return };
let Some(window) = &self.window else { return };
if self.frame_buffer.is_empty() || self.frame_width == 0 || self.frame_height == 0 {
return;
}
let size = window.inner_size();
if size.width == 0 || size.height == 0 {
return;
}
// Resize surface if needed
let width = NonZeroU32::new(size.width).unwrap();
let height = NonZeroU32::new(size.height).unwrap();
if let Err(e) = surface.resize(width, height) {
error!("Failed to resize surface: {}", e);
return;
}
let mut buffer = match surface.buffer_mut() {
Ok(b) => b,
Err(e) => {
error!("Failed to get surface buffer: {}", e);
return;
}
};
// Simple nearest-neighbor scaling
let scale_x = self.frame_width as f32 / size.width as f32;
let scale_y = self.frame_height as f32 / size.height as f32;
for y in 0..size.height {
for x in 0..size.width {
let src_x = ((x as f32 * scale_x) as u32).min(self.frame_width - 1);
let src_y = ((y as f32 * scale_y) as u32).min(self.frame_height - 1);
let src_idx = (src_y * self.frame_width + src_x) as usize;
let dst_idx = (y * size.width + x) as usize;
if src_idx < self.frame_buffer.len() && dst_idx < buffer.len() {
buffer[dst_idx] = self.frame_buffer[src_idx];
}
}
}
if let Err(e) = buffer.present() {
error!("Failed to present buffer: {}", e);
}
}
fn send_mouse_event(&self, event_type: proto::MouseEventType, x: i32, y: i32) {
let event = proto::MouseEvent {
x,
y,
buttons: Some(proto::MouseButtons::default()),
wheel_delta_x: 0,
wheel_delta_y: 0,
event_type: event_type as i32,
};
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
}
fn send_mouse_button(&self, button: MouseButton, state: ElementState) {
let event_type = match state {
ElementState::Pressed => proto::MouseEventType::MouseDown,
ElementState::Released => proto::MouseEventType::MouseUp,
};
let mut buttons = proto::MouseButtons::default();
match button {
MouseButton::Left => buttons.left = true,
MouseButton::Right => buttons.right = true,
MouseButton::Middle => buttons.middle = true,
_ => {}
}
let event = proto::MouseEvent {
x: self.mouse_x,
y: self.mouse_y,
buttons: Some(buttons),
wheel_delta_x: 0,
wheel_delta_y: 0,
event_type: event_type as i32,
};
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
}
fn send_mouse_wheel(&self, delta_x: i32, delta_y: i32) {
let event = proto::MouseEvent {
x: self.mouse_x,
y: self.mouse_y,
buttons: Some(proto::MouseButtons::default()),
wheel_delta_x: delta_x,
wheel_delta_y: delta_y,
event_type: proto::MouseEventType::MouseWheel as i32,
};
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
}
fn send_key_event(&self, key: PhysicalKey, state: ElementState) {
let vk_code = match key {
PhysicalKey::Code(code) => keycode_to_vk(code),
_ => return,
};
let event = proto::KeyEvent {
down: state == ElementState::Pressed,
key_type: proto::KeyEventType::KeyVk as i32,
vk_code,
scan_code: 0,
unicode: String::new(),
modifiers: Some(proto::Modifiers::default()),
};
let _ = self.input_tx.try_send(InputEvent::Key(event));
}
fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) {
let Some(window) = &self.window else {
return (x as i32, y as i32);
};
let size = window.inner_size();
if size.width == 0 || size.height == 0 || self.frame_width == 0 || self.frame_height == 0 {
return (x as i32, y as i32);
}
// Scale from window coordinates to frame coordinates
let scale_x = self.frame_width as f64 / size.width as f64;
let scale_y = self.frame_height as f64 / size.height as f64;
let frame_x = (x * scale_x) as i32;
let frame_y = (y * scale_y) as i32;
(frame_x, frame_y)
}
}
impl ApplicationHandler for ViewerApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let window_attrs = Window::default_attributes()
.with_title("GuruConnect Viewer")
.with_inner_size(LogicalSize::new(1280, 720));
let window = Arc::new(event_loop.create_window(window_attrs).unwrap());
// Create software rendering surface
let context = softbuffer::Context::new(window.clone()).unwrap();
let surface = softbuffer::Surface::new(&context, window.clone()).unwrap();
self.window = Some(window.clone());
self.surface = Some(surface);
// Install keyboard hook
#[cfg(windows)]
{
let input_tx = self.input_tx.clone();
match input::KeyboardHook::new(input_tx) {
Ok(hook) => {
info!("Keyboard hook installed");
self.keyboard_hook = Some(hook);
}
Err(e) => {
error!("Failed to install keyboard hook: {}", e);
}
}
}
info!("Window created");
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
// Check for incoming viewer events (non-blocking)
while let Ok(viewer_event) = self.viewer_rx.try_recv() {
match viewer_event {
ViewerEvent::Frame(frame) => {
self.process_frame(frame);
}
ViewerEvent::Connected => {
info!("Connected to remote session");
}
ViewerEvent::Disconnected(reason) => {
warn!("Disconnected: {}", reason);
event_loop.exit();
}
ViewerEvent::CursorPosition(_x, _y, _visible) => {
// Could update cursor display here
}
ViewerEvent::CursorShape(_shape) => {
// Could update cursor shape here
}
}
}
match event {
WindowEvent::CloseRequested => {
info!("Window close requested");
event_loop.exit();
}
WindowEvent::RedrawRequested => {
self.render();
}
WindowEvent::Resized(size) => {
debug!("Window resized to {}x{}", size.width, size.height);
if let Some(window) = &self.window {
window.request_redraw();
}
}
WindowEvent::CursorMoved { position, .. } => {
let (x, y) = self.screen_to_frame_coords(position.x, position.y);
self.mouse_x = x;
self.mouse_y = y;
self.send_mouse_event(proto::MouseEventType::MouseMove, x, y);
}
WindowEvent::MouseInput { state, button, .. } => {
self.send_mouse_button(button, state);
}
WindowEvent::MouseWheel { delta, .. } => {
let (dx, dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x as i32 * 120, y as i32 * 120),
MouseScrollDelta::PixelDelta(pos) => (pos.x as i32, pos.y as i32),
};
self.send_mouse_wheel(dx, dy);
}
WindowEvent::KeyboardInput { event, .. } => {
// Note: This handles keys that aren't captured by the low-level hook
// The hook handles Win key and other special keys
if !event.repeat {
self.send_key_event(event.physical_key, event.state);
}
}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
// Keep checking for events
event_loop.set_control_flow(ControlFlow::Poll);
// Process Windows messages for keyboard hook
#[cfg(windows)]
input::pump_messages();
// Request redraw periodically to check for new frames
if let Some(window) = &self.window {
window.request_redraw();
}
}
}
/// Run the viewer window
pub async fn run_window(
viewer_rx: mpsc::Receiver<ViewerEvent>,
input_tx: mpsc::Sender<InputEvent>,
) -> Result<()> {
let event_loop = EventLoop::new()?;
let mut app = ViewerApp::new(viewer_rx, input_tx);
event_loop.run_app(&mut app)?;
Ok(())
}
/// Convert winit KeyCode to Windows virtual key code
fn keycode_to_vk(code: KeyCode) -> u32 {
match code {
// Letters
KeyCode::KeyA => 0x41,
KeyCode::KeyB => 0x42,
KeyCode::KeyC => 0x43,
KeyCode::KeyD => 0x44,
KeyCode::KeyE => 0x45,
KeyCode::KeyF => 0x46,
KeyCode::KeyG => 0x47,
KeyCode::KeyH => 0x48,
KeyCode::KeyI => 0x49,
KeyCode::KeyJ => 0x4A,
KeyCode::KeyK => 0x4B,
KeyCode::KeyL => 0x4C,
KeyCode::KeyM => 0x4D,
KeyCode::KeyN => 0x4E,
KeyCode::KeyO => 0x4F,
KeyCode::KeyP => 0x50,
KeyCode::KeyQ => 0x51,
KeyCode::KeyR => 0x52,
KeyCode::KeyS => 0x53,
KeyCode::KeyT => 0x54,
KeyCode::KeyU => 0x55,
KeyCode::KeyV => 0x56,
KeyCode::KeyW => 0x57,
KeyCode::KeyX => 0x58,
KeyCode::KeyY => 0x59,
KeyCode::KeyZ => 0x5A,
// Numbers
KeyCode::Digit0 => 0x30,
KeyCode::Digit1 => 0x31,
KeyCode::Digit2 => 0x32,
KeyCode::Digit3 => 0x33,
KeyCode::Digit4 => 0x34,
KeyCode::Digit5 => 0x35,
KeyCode::Digit6 => 0x36,
KeyCode::Digit7 => 0x37,
KeyCode::Digit8 => 0x38,
KeyCode::Digit9 => 0x39,
// Function keys
KeyCode::F1 => 0x70,
KeyCode::F2 => 0x71,
KeyCode::F3 => 0x72,
KeyCode::F4 => 0x73,
KeyCode::F5 => 0x74,
KeyCode::F6 => 0x75,
KeyCode::F7 => 0x76,
KeyCode::F8 => 0x77,
KeyCode::F9 => 0x78,
KeyCode::F10 => 0x79,
KeyCode::F11 => 0x7A,
KeyCode::F12 => 0x7B,
// Special keys
KeyCode::Escape => 0x1B,
KeyCode::Tab => 0x09,
KeyCode::CapsLock => 0x14,
KeyCode::ShiftLeft => 0x10,
KeyCode::ShiftRight => 0x10,
KeyCode::ControlLeft => 0x11,
KeyCode::ControlRight => 0x11,
KeyCode::AltLeft => 0x12,
KeyCode::AltRight => 0x12,
KeyCode::Space => 0x20,
KeyCode::Enter => 0x0D,
KeyCode::Backspace => 0x08,
KeyCode::Delete => 0x2E,
KeyCode::Insert => 0x2D,
KeyCode::Home => 0x24,
KeyCode::End => 0x23,
KeyCode::PageUp => 0x21,
KeyCode::PageDown => 0x22,
// Arrow keys
KeyCode::ArrowUp => 0x26,
KeyCode::ArrowDown => 0x28,
KeyCode::ArrowLeft => 0x25,
KeyCode::ArrowRight => 0x27,
// Numpad
KeyCode::NumLock => 0x90,
KeyCode::Numpad0 => 0x60,
KeyCode::Numpad1 => 0x61,
KeyCode::Numpad2 => 0x62,
KeyCode::Numpad3 => 0x63,
KeyCode::Numpad4 => 0x64,
KeyCode::Numpad5 => 0x65,
KeyCode::Numpad6 => 0x66,
KeyCode::Numpad7 => 0x67,
KeyCode::Numpad8 => 0x68,
KeyCode::Numpad9 => 0x69,
KeyCode::NumpadAdd => 0x6B,
KeyCode::NumpadSubtract => 0x6D,
KeyCode::NumpadMultiply => 0x6A,
KeyCode::NumpadDivide => 0x6F,
KeyCode::NumpadDecimal => 0x6E,
KeyCode::NumpadEnter => 0x0D,
// Punctuation
KeyCode::Semicolon => 0xBA,
KeyCode::Equal => 0xBB,
KeyCode::Comma => 0xBC,
KeyCode::Minus => 0xBD,
KeyCode::Period => 0xBE,
KeyCode::Slash => 0xBF,
KeyCode::Backquote => 0xC0,
KeyCode::BracketLeft => 0xDB,
KeyCode::Backslash => 0xDC,
KeyCode::BracketRight => 0xDD,
KeyCode::Quote => 0xDE,
// Other
KeyCode::PrintScreen => 0x2C,
KeyCode::ScrollLock => 0x91,
KeyCode::Pause => 0x13,
_ => 0,
}
}

View File

@@ -0,0 +1,102 @@
//! WebSocket transport for viewer-server communication
use crate::proto;
use anyhow::{anyhow, Result};
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use prost::Message as ProstMessage;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio_tungstenite::{
connect_async,
tungstenite::protocol::Message as WsMessage,
MaybeTlsStream, WebSocketStream,
};
use tokio::net::TcpStream;
use tracing::{debug, error, trace};
pub type WsSender = futures_util::stream::SplitSink<
WebSocketStream<MaybeTlsStream<TcpStream>>,
WsMessage,
>;
pub type WsReceiver = futures_util::stream::SplitStream<
WebSocketStream<MaybeTlsStream<TcpStream>>,
>;
/// Receiver wrapper that parses protobuf messages
pub struct MessageReceiver {
inner: WsReceiver,
}
impl MessageReceiver {
pub async fn recv(&mut self) -> Option<proto::Message> {
loop {
match self.inner.next().await {
Some(Ok(WsMessage::Binary(data))) => {
match proto::Message::decode(Bytes::from(data)) {
Ok(msg) => return Some(msg),
Err(e) => {
error!("Failed to decode message: {}", e);
continue;
}
}
}
Some(Ok(WsMessage::Close(_))) => {
debug!("WebSocket closed");
return None;
}
Some(Ok(WsMessage::Ping(_))) => {
trace!("Received ping");
continue;
}
Some(Ok(WsMessage::Pong(_))) => {
trace!("Received pong");
continue;
}
Some(Ok(_)) => continue,
Some(Err(e)) => {
error!("WebSocket error: {}", e);
return None;
}
None => return None,
}
}
}
}
/// Connect to the GuruConnect server
pub async fn connect(url: &str, token: &str) -> Result<(WsSender, MessageReceiver)> {
// Add auth token to URL
let full_url = if token.is_empty() {
url.to_string()
} else if url.contains('?') {
format!("{}&token={}", url, urlencoding::encode(token))
} else {
format!("{}?token={}", url, urlencoding::encode(token))
};
debug!("Connecting to {}", full_url);
let (ws_stream, _) = connect_async(&full_url)
.await
.map_err(|e| anyhow!("Failed to connect: {}", e))?;
let (sender, receiver) = ws_stream.split();
Ok((sender, MessageReceiver { inner: receiver }))
}
/// Send a protobuf message over the WebSocket
pub async fn send_message(
sender: &Arc<Mutex<WsSender>>,
msg: &proto::Message,
) -> Result<()> {
let mut buf = Vec::with_capacity(msg.encoded_len());
msg.encode(&mut buf)?;
let mut sender = sender.lock().await;
sender.send(WsMessage::Binary(buf)).await?;
Ok(())
}