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

914
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ resolver = "2"
members = [
"agent",
"server",
"viewer",
]
[workspace.package]

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;
}

View File

@@ -1,6 +1,6 @@
//! Low-level keyboard hook for capturing all keys including Win key
use crate::InputEvent;
use super::InputEvent;
#[cfg(windows)]
use crate::proto;
use anyhow::Result;

View File

@@ -1,42 +1,17 @@
//! GuruConnect Native Viewer
//! Viewer module - Native remote desktop viewer with full keyboard capture
//!
//! Native remote desktop viewer with full keyboard capture including Win key.
//! This module provides the viewer functionality for connecting to remote
//! GuruConnect sessions with low-level keyboard hooks for Win key capture.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod proto;
mod transport;
mod render;
mod input;
mod render;
mod transport;
use crate::proto;
use anyhow::Result;
use clap::Parser;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tracing::{info, error, warn, Level};
use tracing_subscriber::FmtSubscriber;
/// GuruConnect Native Viewer
#[derive(Parser, Debug)]
#[command(name = "guruconnect-viewer")]
#[command(about = "Native remote desktop viewer with full keyboard capture")]
struct Args {
/// Server URL (e.g., wss://connect.azcomputerguru.com/ws/viewer)
#[arg(short, long, default_value = "wss://connect.azcomputerguru.com/ws/viewer")]
server: String,
/// Session ID to connect to
#[arg(short = 'i', long)]
session_id: String,
/// API key for authentication
#[arg(short, long, default_value = "dev-key")]
api_key: String,
/// Enable verbose logging
#[arg(short, long)]
verbose: bool,
}
use tracing::{info, error, warn};
#[derive(Debug, Clone)]
pub enum ViewerEvent {
@@ -54,43 +29,21 @@ pub enum InputEvent {
SpecialKey(proto::SpecialKeyEvent),
}
fn main() -> Result<()> {
let args = Args::parse();
// Initialize logging
let level = if args.verbose { Level::DEBUG } else { Level::INFO };
FmtSubscriber::builder()
.with_max_level(level)
.with_target(false)
.init();
/// 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: {}", args.server);
info!("Session: {}", args.session_id);
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);
// Run the viewer
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
run_viewer(args, viewer_tx, viewer_rx, input_tx, input_rx).await
})
}
async fn run_viewer(
args: Args,
viewer_tx: mpsc::Sender<ViewerEvent>,
viewer_rx: mpsc::Receiver<ViewerEvent>,
input_tx: mpsc::Sender<InputEvent>,
mut input_rx: mpsc::Receiver<InputEvent>,
) -> Result<()> {
// Connect to server
let ws_url = format!("{}?session_id={}", args.server, args.session_id);
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, &args.api_key).await?;
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");
@@ -100,6 +53,7 @@ async fn run_viewer(
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 {

View File

@@ -1,8 +1,9 @@
//! Window rendering and frame display
use crate::{ViewerEvent, InputEvent, proto};
use super::{ViewerEvent, InputEvent};
use crate::proto;
#[cfg(windows)]
use crate::input;
use super::input;
use anyhow::Result;
use std::num::NonZeroU32;
use std::sync::Arc;

View File

@@ -1,66 +0,0 @@
[package]
name = "guruconnect-viewer"
version = "0.1.0"
edition = "2021"
authors = ["AZ Computer Guru"]
description = "GuruConnect Native Remote Desktop Viewer"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] }
# WebSocket
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"
url = "2"
# Windowing
winit = { version = "0.30", features = ["rwh_06"] }
softbuffer = "0.4"
raw-window-handle = "0.6"
# Compression
zstd = "0.13"
# Protocol (protobuf)
prost = "0.13"
prost-types = "0.13"
bytes = "1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
anyhow = "1"
thiserror = "1"
# UUID
uuid = { version = "1", features = ["v4", "serde"] }
# CLI args
clap = { version = "4", features = ["derive"] }
[target.'cfg(windows)'.dependencies]
# Windows APIs for low-level keyboard hooks
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
]}
[build-dependencies]
prost-build = "0.13"
[profile.release]
lto = true
codegen-units = 1
opt-level = "z"
strip = true
panic = "abort"

View File

@@ -1,9 +0,0 @@
use std::io::Result;
fn main() -> Result<()> {
println!("cargo:rerun-if-changed=../proto/guruconnect.proto");
prost_build::compile_protos(&["../proto/guruconnect.proto"], &["../proto/"])?;
Ok(())
}

View File

@@ -1,4 +0,0 @@
//! Protocol buffer definitions
// Include generated protobuf code
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));