diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 2169c35..498ff00 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -22,6 +22,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } base64 = "0.22" png = "0.17" futures-util = "0.3" +colored = "2" + +[build-dependencies] +winres = "0.1" [target.'cfg(windows)'.dependencies] windows = { version = "0.54", features = [ diff --git a/crates/client/build.rs b/crates/client/build.rs new file mode 100644 index 0000000..d8cbd5f --- /dev/null +++ b/crates/client/build.rs @@ -0,0 +1,8 @@ +fn main() { + #[cfg(target_os = "windows")] + { + let mut res = winres::WindowsResource::new(); + res.set_icon("../../assets/logo.ico"); + res.compile().expect("Failed to compile Windows resources"); + } +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index abea5e4..86e7c87 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -2,13 +2,12 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use chrono::Local; +use colored::Colorize; use futures_util::{SinkExt, StreamExt}; use native_tls::TlsConnector; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector}; -use tracing::{error, warn}; use helios_common::{ClientMessage, ServerMessage}; @@ -17,23 +16,49 @@ mod screenshot; mod input; mod windows_mgmt; -fn ts() -> String { - Local::now().format("%H:%M:%S").to_string() +// ─── CLI Output ───────────────────────────────────────────────────────────── + +fn banner() { + println!(); + println!(" {} HELIOS REMOTE", "☀".yellow().bold()); + println!(" {}", "─".repeat(45).dimmed()); } -macro_rules! log_info { - ($($arg:tt)*) => { println!("[{}] {}", ts(), format!($($arg)*)) }; -} -macro_rules! log_cmd { - ($($arg:tt)*) => { println!("[{}] [CMD] {}", ts(), format!($($arg)*)) }; +macro_rules! log_status { + ($($arg:tt)*) => { + println!(" {} {}", "→".cyan().bold(), format!($($arg)*)); + }; } + macro_rules! log_ok { - ($($arg:tt)*) => { println!("[{}] [OK] {}", ts(), format!($($arg)*)) }; + ($($arg:tt)*) => { + println!(" {} {}", "✓".green().bold(), format!($($arg)*)); + }; } + macro_rules! log_err { - ($($arg:tt)*) => { println!("[{}] [ERR] {}", ts(), format!($($arg)*)) }; + ($($arg:tt)*) => { + println!(" {} {}", "✗".red().bold(), format!($($arg)*)); + }; } +macro_rules! log_cmd { + ($($arg:tt)*) => { + println!(" {} {}", "⚡".yellow().bold(), format!($($arg)*)); + }; +} + +fn session_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + format!("{:06x}", t & 0xFFFFFF) +} + +// ──────────────────────────────────────────────────────────────────────────── + #[derive(Debug, Serialize, Deserialize)] struct Config { relay_url: String, @@ -66,7 +91,7 @@ impl Config { fn prompt_config() -> Config { let relay_url = { - print!("Relay server URL [default: wss://remote.agent-helios.me/ws]: "); + print!(" Relay server URL [default: wss://remote.agent-helios.me/ws]: "); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); let trimmed = input.trim(); @@ -78,14 +103,14 @@ fn prompt_config() -> Config { }; let api_key = { - print!("API Key: "); + print!(" API Key: "); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); input.trim().to_string() }; let label = { - print!("Label for this machine (optional, press Enter to skip): "); + print!(" Label for this machine (optional, press Enter to skip): "); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); let trimmed = input.trim().to_string(); @@ -97,21 +122,24 @@ fn prompt_config() -> Config { #[tokio::main] async fn main() { - println!("=== Helios Remote Client ==="); + // Suppress tracing output by default + if std::env::var("RUST_LOG").is_err() { + unsafe { std::env::set_var("RUST_LOG", "off"); } + } + + banner(); // Load or prompt for config let config = match Config::load() { - Some(c) => { - log_info!("Config loaded from {:?}", Config::config_path()); - c - } + Some(c) => c, None => { - log_info!("No config found — first-time setup"); + log_status!("No config found — first-time setup"); + println!(); let c = prompt_config(); if let Err(e) = c.save() { log_err!("Failed to save config: {e}"); } else { - log_info!("Config saved to {:?}", Config::config_path()); + log_ok!("Config saved"); } c } @@ -125,17 +153,34 @@ async fn main() { const MAX_BACKOFF: Duration = Duration::from_secs(30); loop { - log_info!("Connecting to {}...", config.relay_url); - // Build TLS connector - accepts self-signed certs for internal CA (Caddy tls internal) + let host = config.relay_url + .trim_start_matches("wss://") + .trim_start_matches("ws://") + .split('/') + .next() + .unwrap_or(&config.relay_url); + + log_status!("Connecting to {}...", host); + let tls_connector = TlsConnector::builder() .danger_accept_invalid_certs(true) .build() .expect("TLS connector build failed"); let connector = Connector::NativeTls(tls_connector); + match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await { Ok((ws_stream, _)) => { - log_info!("Connected!"); - backoff = Duration::from_secs(1); // reset on success + let sid = session_id(); + let label = config.label.clone().unwrap_or_else(|| hostname()); + log_ok!( + "Connected {} {} {} Session {}", + "·".dimmed(), + label.bold(), + "·".dimmed(), + sid.dimmed() + ); + println!(); + backoff = Duration::from_secs(1); let (mut write, mut read) = ws_stream.split(); @@ -151,17 +196,15 @@ async fn main() { continue; } - // Shared write half let write = Arc::new(Mutex::new(write)); - // Process messages while let Some(msg_result) = read.next().await { match msg_result { Ok(Message::Text(text)) => { let server_msg: ServerMessage = match serde_json::from_str(&text) { Ok(m) => m, Err(e) => { - log_err!("Failed to parse server message: {e} | raw: {text}"); + log_err!("Failed to parse server message: {e}"); continue; } }; @@ -183,18 +226,16 @@ async fn main() { let _ = w.send(Message::Pong(data)).await; } Ok(Message::Close(_)) => { - log_info!("Server closed connection"); + log_err!("Connection lost — reconnecting..."); break; } Err(e) => { - log_err!("WebSocket error: {e}"); + log_err!("Connection lost: {e} — reconnecting..."); break; } _ => {} } } - - warn!("Disconnected. Reconnecting in {:?}...", backoff); } Err(e) => { log_err!("Connection failed: {e}"); @@ -206,6 +247,29 @@ async fn main() { } } +fn hostname() -> String { + std::fs::read_to_string("/etc/hostname") + .unwrap_or_default() + .trim() + .to_string() + .or_else(|| std::env::var("COMPUTERNAME").ok()) + .unwrap_or_else(|| "unknown".to_string()) +} + +trait OrElseString { + fn or_else(self, f: impl FnOnce() -> Option) -> String; + fn unwrap_or_else(self, f: impl FnOnce() -> String) -> String; +} + +impl OrElseString for String { + fn or_else(self, f: impl FnOnce() -> Option) -> String { + if self.is_empty() { f().unwrap_or_default() } else { self } + } + fn unwrap_or_else(self, f: impl FnOnce() -> String) -> String { + if self.is_empty() { f() } else { self } + } +} + async fn handle_message( msg: ServerMessage, shell: Arc>, @@ -215,7 +279,7 @@ async fn handle_message( log_cmd!("screenshot"); match screenshot::take_screenshot() { Ok((image_base64, width, height)) => { - log_ok!("screenshot taken ({}x{})", width, height); + log_ok!("Done {} {}×{}", "·".dimmed(), width, height); ClientMessage::ScreenshotResponse { request_id, image_base64, @@ -234,11 +298,17 @@ async fn handle_message( } ServerMessage::ExecRequest { request_id, command } => { - log_cmd!("exec: {command}"); + log_cmd!("exec › {}", command); let mut sh = shell.lock().await; match sh.run(&command).await { Ok((stdout, stderr, exit_code)) => { - log_ok!("exec completed (exit {})", exit_code); + let out = stdout.trim().lines().next().unwrap_or("").to_string(); + if out.is_empty() { + log_ok!("Done {} exit {}", "·".dimmed(), exit_code); + } else { + log_ok!("{} {} exit {}", out, "·".dimmed(), exit_code); + } + let _ = stderr; ClientMessage::ExecResponse { request_id, stdout, @@ -247,11 +317,11 @@ async fn handle_message( } } Err(e) => { - log_err!("exec failed for {:?}: {e}", command); + log_err!("exec failed: {e}"); ClientMessage::Error { request_id, message: format!( - "Exec failed for command {:?}.\nError: {e}\nContext: persistent shell may have died.", + "Exec failed for command {:?}.\nError: {e}", command ), } @@ -260,14 +330,14 @@ async fn handle_message( } ServerMessage::ClickRequest { request_id, x, y, button } => { - log_cmd!("click {} {} {:?}", x, y, button); + log_cmd!("click ({x}, {y}) {:?}", button); match input::click(x, y, &button) { Ok(()) => { - log_ok!("click done"); + log_ok!("Done"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("click failed at ({x},{y}): {e}"); + log_err!("click failed: {e}"); ClientMessage::Error { request_id, message: format!("Click at ({x},{y}) failed: {e}"), @@ -277,10 +347,10 @@ async fn handle_message( } ServerMessage::TypeRequest { request_id, text } => { - log_cmd!("type: {} chars", text.len()); + log_cmd!("type {} chars", text.len()); match input::type_text(&text) { Ok(()) => { - log_ok!("type done"); + log_ok!("Done"); ClientMessage::Ack { request_id } } Err(e) => { @@ -297,7 +367,7 @@ async fn handle_message( log_cmd!("list-windows"); match windows_mgmt::list_windows() { Ok(windows) => { - log_ok!("list-windows: {} windows", windows.len()); + log_ok!("{} windows", windows.len()); ClientMessage::ListWindowsResponse { request_id, windows } } Err(e) => { @@ -311,7 +381,7 @@ async fn handle_message( log_cmd!("minimize-all"); match windows_mgmt::minimize_all() { Ok(()) => { - log_ok!("minimize-all done"); + log_ok!("Done"); ClientMessage::Ack { request_id } } Err(e) => { @@ -322,10 +392,10 @@ async fn handle_message( } ServerMessage::FocusWindowRequest { request_id, window_id } => { - log_cmd!("focus-window: {window_id}"); + log_cmd!("focus-window {window_id}"); match windows_mgmt::focus_window(window_id) { Ok(()) => { - log_ok!("focus-window done"); + log_ok!("Done"); ClientMessage::Ack { request_id } } Err(e) => { @@ -336,10 +406,10 @@ async fn handle_message( } ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => { - log_cmd!("maximize-and-focus: {window_id}"); + log_cmd!("maximize-and-focus {window_id}"); match windows_mgmt::maximize_and_focus(window_id) { Ok(()) => { - log_ok!("maximize-and-focus done"); + log_ok!("Done"); ClientMessage::Ack { request_id } } Err(e) => { @@ -350,12 +420,11 @@ async fn handle_message( } ServerMessage::Ack { request_id } => { - // Nothing to do - server acked something we sent ClientMessage::Ack { request_id } } ServerMessage::Error { request_id, message } => { - log_err!("server error (req={request_id:?}): {message}"); + log_err!("server error: {message}"); if let Some(rid) = request_id { ClientMessage::Ack { request_id: rid } } else {