Unify agent and viewer into single guruconnect binary

- Renamed package from guruconnect-agent to guruconnect
- Added CLI subcommands: agent, view, install, uninstall, launch
- Moved viewer code into agent/src/viewer module
- Added install module with:
  - UAC elevation attempt with user-install fallback
  - Protocol handler registration (guruconnect://)
  - System-wide install to Program Files or user install to LocalAppData
- Single binary now handles both receiving and initiating connections
- Protocol URL format: guruconnect://view/SESSION_ID?token=API_KEY

Usage:
  guruconnect agent              - Run as background agent
  guruconnect view <session_id>  - View a remote session
  guruconnect install            - Install and register protocol
  guruconnect launch <url>       - Handle guruconnect:// URL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AZ Computer Guru
2025-12-29 18:56:18 -07:00
parent a8ffa4bd83
commit 05ab8a8bf4
12 changed files with 1463 additions and 396 deletions

View File

@@ -1,11 +1,14 @@
[package]
name = "guruconnect-agent"
name = "guruconnect"
version = "0.1.0"
edition = "2021"
authors = ["AZ Computer Guru"]
description = "GuruConnect Remote Desktop Agent"
description = "GuruConnect Remote Desktop - Agent and Viewer"
[dependencies]
# CLI
clap = { version = "4", features = ["derive"] }
# Async runtime
tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] }
@@ -13,6 +16,11 @@ tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread",
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"
# Windowing (for viewer)
winit = { version = "0.30", features = ["rwh_06"] }
softbuffer = "0.4"
raw-window-handle = "0.6"
# Compression
zstd = "0.13"
@@ -58,8 +66,11 @@ muda = "0.15" # Menu for tray icon
# Image handling for tray icon
image = { version = "0.25", default-features = false, features = ["png"] }
# URL parsing
url = "2"
[target.'cfg(windows)'.dependencies]
# Windows APIs for screen capture and input
# Windows APIs for screen capture, input, and shell operations
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
@@ -69,10 +80,12 @@ windows = { version = "0.58", features = [
"Win32_Graphics_Direct3D11",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
"Win32_System_Registry",
"Win32_System_Console",
"Win32_System_Environment",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Pipes",
@@ -88,16 +101,9 @@ prost-build = "0.13"
winres = "0.1"
[[bin]]
name = "guruconnect-agent"
name = "guruconnect"
path = "src/main.rs"
[[bin]]
name = "guruconnect-sas-service"
path = "src/bin/sas_service.rs"
[profile.release]
lto = true
codegen-units = 1
opt-level = "z"
strip = true
panic = "abort"

364
agent/src/install.rs Normal file
View File

@@ -0,0 +1,364 @@
//! 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(())
}
/// Parse a guruconnect:// URL and extract session parameters
pub fn parse_protocol_url(url: &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
let url = url::Url::parse(url)
.map_err(|e| anyhow!("Invalid URL: {}", e))?;
if url.scheme() != "guruconnect" {
return Err(anyhow!("Invalid scheme: expected guruconnect://"));
}
let path = url.path().trim_start_matches('/');
let parts: Vec<&str> = path.split('/').collect();
if parts.is_empty() {
return Err(anyhow!("Missing action in URL"));
}
let action = parts[0];
let session_id = parts.get(1).map(|s| s.to_string())
.ok_or_else(|| 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

@@ -1,12 +1,13 @@
//! GuruConnect Agent - Remote Desktop Agent for Windows
//! GuruConnect - Remote Desktop Agent and Viewer
//!
//! Provides screen capture, input injection, and remote control capabilities.
//! Single binary for both agent (receiving connections) and viewer (initiating connections).
//!
//! Usage:
//! guruconnect-agent.exe [support_code]
//!
//! If a support code is provided, the agent will connect using that code
//! for a one-time support session.
//! 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")]
@@ -16,42 +17,227 @@ mod chat;
mod config;
mod encoder;
mod input;
mod install;
mod sas_client;
mod session;
mod startup;
mod transport;
mod tray;
mod viewer;
pub mod proto {
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
}
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};
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, MB_ICONERROR};
#[cfg(windows)]
use windows::core::PCWSTR;
#[cfg(windows)]
use windows::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY};
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
#[cfg(windows)]
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
#[cfg(windows)]
use windows::Win32::System::Console::{AllocConsole, FreeConsole, GetConsoleWindow};
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW, SW_HIDE};
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
/// Extract a 6-digit support code from the executable's filename.
/// Looks for patterns like "GuruConnect-123456.exe" or "123456.exe"
/// GuruConnect Remote Desktop
#[derive(Parser)]
#[command(name = "guruconnect")]
#[command(version, 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,
}
#[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,
},
}
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 v{}", env!("CARGO_PKG_VERSION"));
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)
}
None => {
// Legacy mode: if a support code was provided, run as agent
if let Some(code) = cli.support_code {
run_agent_mode(Some(code))
} else {
// No args: check if we should auto-detect mode
// For now, default to agent mode
run_agent_mode(None)
}
}
}
}
/// 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");
}
// Also check for support code in filename (legacy compatibility)
let code = support_code.or_else(extract_code_from_filename);
if let Some(ref c) = code {
info!("Support code: {}", c);
}
// Load configuration
let mut config = config::Config::load()?;
config.support_code = code;
info!("Server: {}", config.server_url);
// 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(())
}
/// Extract a 6-digit support code from the executable's filename
fn extract_code_from_filename() -> Option<String> {
// Get the path to the current executable
let exe_path = std::env::current_exe().ok()?;
let filename = exe_path.file_stem()?.to_str()?;
// Look for a 6-digit number in the filename
// Try common patterns: "Name-123456", "Name_123456", "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()) {
@@ -59,7 +245,7 @@ fn extract_code_from_filename() -> Option<String> {
}
}
// Also check if the last 6 characters are digits (e.g., "GuruConnect123456")
// Check if the last 6 characters are digits
if filename.len() >= 6 {
let last_six = &filename[filename.len() - 6..];
if last_six.chars().all(|c| c.is_ascii_digit()) {
@@ -70,45 +256,12 @@ fn extract_code_from_filename() -> Option<String> {
None
}
/// Check if the process is running with elevated privileges (Windows only)
#[cfg(windows)]
fn is_elevated() -> bool {
unsafe {
let mut token_handle = windows::Win32::Foundation::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))]
fn is_elevated() -> bool {
// On non-Windows, check if running as root
unsafe { libc::geteuid() == 0 }
}
/// Show a message box to the user (Windows only)
/// 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;
// Convert strings to wide strings for Windows API
let title_wide: Vec<u16> = OsStr::new(title)
.encode_wide()
.chain(std::iter::once(0))
@@ -129,74 +282,59 @@ fn show_message_box(title: &str, message: &str) {
}
#[cfg(not(windows))]
fn show_message_box(_title: &str, _message: &str) {
// No-op on non-Windows platforms
fn show_message_box(_title: &str, message: &str) {
println!("{}", message);
}
/// Show the debug console window (Windows only)
/// 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 {
// Check if we already have a console
let hwnd = GetConsoleWindow();
if hwnd.0 == std::ptr::null_mut() {
// No console, allocate one
let _ = AllocConsole();
info!("Debug console window opened");
} else {
// Console exists, make sure it's visible
let _ = ShowWindow(hwnd, SW_SHOW);
info!("Debug console window shown");
}
}
}
#[cfg(not(windows))]
fn show_debug_console() {
// No-op on non-Windows platforms
}
#[allow(dead_code)]
fn show_debug_console() {}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::INFO)
.with_target(true)
.with_thread_ids(true)
.init();
info!("GuruConnect Agent v{}", env!("CARGO_PKG_VERSION"));
// Check and log elevation status
if is_elevated() {
info!("Running with elevated (administrator) privileges");
} else {
info!("Running with standard user privileges");
}
// Extract support code from executable filename
// e.g., GuruConnect-123456.exe -> 123456
let support_code = extract_code_from_filename();
if let Some(ref code) = support_code {
info!("Support code from filename: {}", code);
}
// Load configuration
let mut config = config::Config::load()?;
config.support_code = support_code;
info!("Loaded configuration for server: {}", config.server_url);
// Run the agent
if let Err(e) = run_agent(config).await {
error!("Agent error: {}", e);
return Err(e);
}
Ok(())
}
/// Clean up before exiting (remove from startup, etc.)
/// Called when user explicitly ends session or support session completes
/// Clean up before exiting
fn cleanup_on_exit() {
info!("Cleaning up before exit");
if let Err(e) = startup::remove_from_startup() {
@@ -204,44 +342,37 @@ fn cleanup_on_exit() {
}
}
/// Run the agent main loop
async fn run_agent(config: config::Config) -> Result<()> {
// Create session manager with elevation status
let elevated = is_elevated();
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 so we reconnect after reboot
// Persistent agents (no support code) should ALWAYS be in startup
// Support sessions only need startup temporarily while active
// Add to startup
if let Err(e) = startup::add_to_startup() {
warn!("Failed to add to startup: {}. Agent won't persist through reboot.", e);
warn!("Failed to add to startup: {}", e);
}
// Create tray icon
// Only support sessions can be ended by user - persistent agents are admin-managed
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: {}. Continuing without tray.", e);
warn!("Failed to create tray icon: {}", e);
None
}
};
// Create chat controller
let chat_ctrl = chat::ChatController::new();
if chat_ctrl.is_some() {
info!("Chat controller created");
}
// Connect to server and run main loop
loop {
info!("Connecting to server...");
// Check if user requested exit via tray before connecting (support sessions only)
if is_support_session {
if let Some(ref t) = tray {
if t.exit_requested() {
@@ -256,44 +387,32 @@ async fn run_agent(config: config::Config) -> Result<()> {
Ok(_) => {
info!("Connected to server");
// Update tray status
if let Some(ref t) = tray {
t.update_status("Status: Connected");
}
// Run session until disconnect, passing tray and chat for event processing
if let Err(e) = session.run_with_tray(tray.as_ref(), chat_ctrl.as_ref()).await {
let error_msg = e.to_string();
// Check if this is a user-initiated exit
if error_msg.contains("USER_EXIT") {
info!("Session ended by user");
cleanup_on_exit();
return Ok(());
}
// Check if this is a cancellation
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 by the technician.\n\nThis window will close automatically.",
);
// Exit cleanly without reconnecting
show_message_box("Support Session Ended", "The support session was cancelled.");
return Ok(());
}
// Check if this is an admin disconnect (uninstall)
if error_msg.contains("ADMIN_DISCONNECT") {
info!("Session was disconnected by administrator - uninstalling");
info!("Session disconnected by administrator - uninstalling");
if let Err(e) = startup::uninstall() {
warn!("Uninstall failed: {}", e);
}
show_message_box(
"Remote Session Ended",
"The remote support session has been ended by the administrator.\n\nThe agent will be removed from this computer.",
);
show_message_box("Remote Session Ended", "The session was ended by the administrator.");
return Ok(());
}
@@ -303,15 +422,10 @@ async fn run_agent(config: config::Config) -> Result<()> {
Err(e) => {
let error_msg = e.to_string();
// Check if connection was rejected due to cancelled code
if error_msg.contains("cancelled") {
info!("Support code was cancelled before connection");
info!("Support code was cancelled");
cleanup_on_exit();
show_message_box(
"Support Session Cancelled",
"This support session has been cancelled.\n\nPlease contact your technician for a new support code.",
);
// Exit cleanly without reconnecting
show_message_box("Support Session Cancelled", "This support session has been cancelled.");
return Ok(());
}
@@ -319,14 +433,12 @@ async fn run_agent(config: config::Config) -> Result<()> {
}
}
// For support sessions, don't reconnect if something goes wrong
if is_support_session {
info!("Support session ended, not reconnecting");
cleanup_on_exit();
return Ok(());
}
// Wait before reconnecting (persistent agents only - support sessions already exited above)
info!("Reconnecting in 5 seconds...");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}

173
agent/src/viewer/input.rs Normal file
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() {}

121
agent/src/viewer/mod.rs Normal file
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(())
}

508
agent/src/viewer/render.rs Normal file
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,100 @@
//! 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, api_key: &str) -> Result<(WsSender, MessageReceiver)> {
// Add API key to URL
let full_url = if url.contains('?') {
format!("{}&api_key={}", url, api_key)
} else {
format!("{}?api_key={}", url, api_key)
};
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(())
}