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:
parent
1d019fa2b4
commit
e32e09996b
3 changed files with 131 additions and 50 deletions
|
|
@ -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
8
crates/client/build.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue