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