- Add git hash (short and full), branch, commit date
- Add build timestamp and dirty/clean state
- Add build profile (debug/release) and target triple
- New `version-info` command shows all build details
- `--version` now shows version-hash format (e.g., 0.1.0-4614df04)
- Startup logs now include version hash and build info
Example output:
GuruConnect v0.1.0
Git: 4614df04 (clean)
Branch: main
Commit: 2025-12-30 06:30:28 -0700
Built: 2025-12-30 15:25:20 UTC
Profile: release
Target: x86_64-pc-windows-msvc
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
538 lines
16 KiB
Rust
538 lines
16 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 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"));
|
|
}
|
|
|
|
/// Build information embedded at compile time
|
|
pub mod build_info {
|
|
/// Cargo package version (from Cargo.toml)
|
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
/// Git commit hash (short, 8 chars)
|
|
pub const GIT_HASH: &str = env!("GIT_HASH");
|
|
|
|
/// Git commit hash (full)
|
|
pub const GIT_HASH_FULL: &str = env!("GIT_HASH_FULL");
|
|
|
|
/// Git branch name
|
|
pub const GIT_BRANCH: &str = env!("GIT_BRANCH");
|
|
|
|
/// Git dirty state ("clean" or "dirty")
|
|
pub const GIT_DIRTY: &str = env!("GIT_DIRTY");
|
|
|
|
/// Git commit date
|
|
pub const GIT_COMMIT_DATE: &str = env!("GIT_COMMIT_DATE");
|
|
|
|
/// Build timestamp (UTC)
|
|
pub const BUILD_TIMESTAMP: &str = env!("BUILD_TIMESTAMP");
|
|
|
|
/// Build profile (debug/release)
|
|
pub const BUILD_PROFILE: &str = env!("BUILD_PROFILE");
|
|
|
|
/// Target triple (e.g., x86_64-pc-windows-msvc)
|
|
pub const BUILD_TARGET: &str = env!("BUILD_TARGET");
|
|
|
|
/// Short version string for display (version + git hash)
|
|
pub fn short_version() -> String {
|
|
if GIT_DIRTY == "dirty" {
|
|
format!("{}-{}-dirty", VERSION, GIT_HASH)
|
|
} else {
|
|
format!("{}-{}", VERSION, GIT_HASH)
|
|
}
|
|
}
|
|
|
|
/// Full version string with all details
|
|
pub fn full_version() -> String {
|
|
format!(
|
|
"GuruConnect v{}\n\
|
|
Git: {} ({})\n\
|
|
Branch: {}\n\
|
|
Commit: {}\n\
|
|
Built: {}\n\
|
|
Profile: {}\n\
|
|
Target: {}",
|
|
VERSION,
|
|
GIT_HASH,
|
|
GIT_DIRTY,
|
|
GIT_BRANCH,
|
|
GIT_COMMIT_DATE,
|
|
BUILD_TIMESTAMP,
|
|
BUILD_PROFILE,
|
|
BUILD_TARGET
|
|
)
|
|
}
|
|
}
|
|
|
|
use anyhow::Result;
|
|
use clap::{Parser, Subcommand};
|
|
use tracing::{info, error, warn, Level};
|
|
use tracing_subscriber::FmtSubscriber;
|
|
|
|
#[cfg(windows)]
|
|
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, MB_ICONERROR};
|
|
#[cfg(windows)]
|
|
use windows::core::PCWSTR;
|
|
#[cfg(windows)]
|
|
use windows::Win32::System::Console::{AllocConsole, GetConsoleWindow};
|
|
#[cfg(windows)]
|
|
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOW};
|
|
|
|
/// GuruConnect Remote Desktop
|
|
#[derive(Parser)]
|
|
#[command(name = "guruconnect")]
|
|
#[command(version = concat!(env!("CARGO_PKG_VERSION"), "-", env!("GIT_HASH")), about = "Remote desktop agent and viewer")]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
|
|
/// Support code for legacy mode (runs agent with code)
|
|
#[arg(value_name = "SUPPORT_CODE")]
|
|
support_code: Option<String>,
|
|
|
|
/// Enable verbose logging
|
|
#[arg(short, long, global = true)]
|
|
verbose: bool,
|
|
}
|
|
|
|
#[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);
|
|
|
|
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 => {
|
|
// 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 protocol handler is installed
|
|
// If not, run install mode (user likely downloaded from web)
|
|
if !install::is_protocol_handler_registered() {
|
|
info!("Protocol handler not registered, running installer");
|
|
run_install(false)
|
|
} else {
|
|
// Protocol handler exists, run as agent
|
|
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> {
|
|
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
|
|
for part in filename.split(|c| c == '-' || c == '_' || c == '.') {
|
|
let trimmed = part.trim();
|
|
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
|
|
return Some(trimmed.to_string());
|
|
}
|
|
}
|
|
|
|
// Check if 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()) {
|
|
return Some(last_six.to_string());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Show a message box (Windows only)
|
|
#[cfg(windows)]
|
|
fn show_message_box(title: &str, message: &str) {
|
|
use std::ffi::OsStr;
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
let title_wide: Vec<u16> = OsStr::new(title)
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
let message_wide: Vec<u16> = OsStr::new(message)
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
|
|
unsafe {
|
|
MessageBoxW(
|
|
None,
|
|
PCWSTR(message_wide.as_ptr()),
|
|
PCWSTR(title_wide.as_ptr()),
|
|
MB_OK | MB_ICONINFORMATION,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn show_message_box(_title: &str, message: &str) {
|
|
println!("{}", message);
|
|
}
|
|
|
|
/// Show an error message box (Windows only)
|
|
#[cfg(windows)]
|
|
fn show_error_box(title: &str, message: &str) {
|
|
use std::ffi::OsStr;
|
|
use std::os::windows::ffi::OsStrExt;
|
|
|
|
let title_wide: Vec<u16> = OsStr::new(title)
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
let message_wide: Vec<u16> = OsStr::new(message)
|
|
.encode_wide()
|
|
.chain(std::iter::once(0))
|
|
.collect();
|
|
|
|
unsafe {
|
|
MessageBoxW(
|
|
None,
|
|
PCWSTR(message_wide.as_ptr()),
|
|
PCWSTR(title_wide.as_ptr()),
|
|
MB_OK | MB_ICONERROR,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn show_error_box(_title: &str, message: &str) {
|
|
eprintln!("ERROR: {}", message);
|
|
}
|
|
|
|
/// Show debug console window (Windows only)
|
|
#[cfg(windows)]
|
|
#[allow(dead_code)]
|
|
fn show_debug_console() {
|
|
unsafe {
|
|
let hwnd = GetConsoleWindow();
|
|
if hwnd.0 == std::ptr::null_mut() {
|
|
let _ = AllocConsole();
|
|
} else {
|
|
let _ = ShowWindow(hwnd, SW_SHOW);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
#[allow(dead_code)]
|
|
fn show_debug_console() {}
|
|
|
|
/// Clean up before exiting
|
|
fn cleanup_on_exit() {
|
|
info!("Cleaning up before exit");
|
|
if let Err(e) = startup::remove_from_startup() {
|
|
warn!("Failed to remove from startup: {}", e);
|
|
}
|
|
}
|
|
|
|
/// Run the agent main loop
|
|
async fn run_agent(config: config::Config) -> Result<()> {
|
|
let elevated = install::is_elevated();
|
|
let mut session = session::SessionManager::new(config.clone(), elevated);
|
|
let is_support_session = config.support_code.is_some();
|
|
let hostname = config.hostname();
|
|
|
|
// Add to startup
|
|
if let Err(e) = startup::add_to_startup() {
|
|
warn!("Failed to add to startup: {}", e);
|
|
}
|
|
|
|
// Create tray icon
|
|
let tray = match tray::TrayController::new(&hostname, config.support_code.as_deref(), is_support_session) {
|
|
Ok(t) => {
|
|
info!("Tray icon created");
|
|
Some(t)
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to create tray icon: {}", e);
|
|
None
|
|
}
|
|
};
|
|
|
|
// Create chat controller
|
|
let chat_ctrl = chat::ChatController::new();
|
|
|
|
// Connect to server and run main loop
|
|
loop {
|
|
info!("Connecting to server...");
|
|
|
|
if is_support_session {
|
|
if let Some(ref t) = tray {
|
|
if t.exit_requested() {
|
|
info!("Exit requested by user");
|
|
cleanup_on_exit();
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
match session.connect().await {
|
|
Ok(_) => {
|
|
info!("Connected to server");
|
|
|
|
if let Some(ref t) = tray {
|
|
t.update_status("Status: Connected");
|
|
}
|
|
|
|
if let Err(e) = session.run_with_tray(tray.as_ref(), chat_ctrl.as_ref()).await {
|
|
let error_msg = e.to_string();
|
|
|
|
if error_msg.contains("USER_EXIT") {
|
|
info!("Session ended by user");
|
|
cleanup_on_exit();
|
|
return Ok(());
|
|
}
|
|
|
|
if error_msg.contains("SESSION_CANCELLED") {
|
|
info!("Session was cancelled by technician");
|
|
cleanup_on_exit();
|
|
show_message_box("Support Session Ended", "The support session was cancelled.");
|
|
return Ok(());
|
|
}
|
|
|
|
if error_msg.contains("ADMIN_DISCONNECT") {
|
|
info!("Session disconnected by administrator - uninstalling");
|
|
if let Err(e) = startup::uninstall() {
|
|
warn!("Uninstall failed: {}", e);
|
|
}
|
|
show_message_box("Remote Session Ended", "The session was ended by the administrator.");
|
|
return Ok(());
|
|
}
|
|
|
|
if error_msg.contains("ADMIN_UNINSTALL") {
|
|
info!("Uninstall command received from server - uninstalling");
|
|
if let Err(e) = startup::uninstall() {
|
|
warn!("Uninstall failed: {}", e);
|
|
}
|
|
show_message_box("GuruConnect Removed", "This computer has been removed from remote management.");
|
|
return Ok(());
|
|
}
|
|
|
|
if error_msg.contains("ADMIN_RESTART") {
|
|
info!("Restart command received - will reconnect");
|
|
// Don't exit, just let the loop continue to reconnect
|
|
} else {
|
|
error!("Session error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let error_msg = e.to_string();
|
|
|
|
if error_msg.contains("cancelled") {
|
|
info!("Support code was cancelled");
|
|
cleanup_on_exit();
|
|
show_message_box("Support Session Cancelled", "This support session has been cancelled.");
|
|
return Ok(());
|
|
}
|
|
|
|
error!("Connection failed: {}", e);
|
|
}
|
|
}
|
|
|
|
if is_support_session {
|
|
info!("Support session ended, not reconnecting");
|
|
cleanup_on_exit();
|
|
return Ok(());
|
|
}
|
|
|
|
info!("Reconnecting in 5 seconds...");
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
|
|
}
|
|
}
|