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