feat(client): modern CLI output + Windows exe icon

- Replace timestamp-based log macros with colored CLI output system
  using the `colored` crate
- Banner on startup: ☀ HELIOS REMOTE + separator line
- Colored prefixes: cyan → (status), green ✓ (ok), red ✗ (error),
  yellow  (incoming commands)
- Session info on connect: ✓ Connected · <label> · Session <id>
- No timestamps, no [CMD]/[OK]/[ERR] prefixes
- Suppress tracing output by default (RUST_LOG=off unless set)
- Add build.rs to embed logo.ico as Windows resource via winres
- Add winres as build-dependency in client Cargo.toml
This commit is contained in:
Helios Agent 2026-03-03 14:02:17 +01:00
parent 1d019fa2b4
commit e32e09996b
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
3 changed files with 131 additions and 50 deletions

View file

@ -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 = [

8
crates/client/build.rs Normal file
View file

@ -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");
}
}

View file

@ -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>) -> String;
fn unwrap_or_else(self, f: impl FnOnce() -> String) -> String;
}
impl OrElseString for String {
fn or_else(self, f: impl FnOnce() -> Option<String>) -> 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<Mutex<shell::PersistentShell>>,
@ -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 {