client: verbose CLI output, TOML config in APPDATA, desktop install

- install.ps1: place exe on Desktop instead of TEMP, start with visible window
- main.rs: banner on startup, [CMD]/[OK]/[ERR] prefixed logs with HH:MM:SS timestamps
- Config: switch from JSON to TOML (config.toml in %APPDATA%\helios-remote\)
- First-run wizard prompts for Relay URL + API Key (relay_code -> api_key)
- Add chrono + toml deps to Cargo.toml
This commit is contained in:
Helios 2026-03-03 13:55:22 +01:00
parent 6e044e3e05
commit 1d019fa2b4
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
3 changed files with 104 additions and 67 deletions

View file

@ -13,6 +13,8 @@ tokio-tungstenite = { version = "0.21", features = ["connect", "native-tls"] }
native-tls = { version = "0.2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
chrono = "0.4"
helios-common = { path = "../common" }
dirs = "5"
tracing = "0.1"

View file

@ -2,12 +2,13 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chrono::Local;
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, info, warn};
use tracing::{error, warn};
use helios_common::{ClientMessage, ServerMessage};
@ -16,10 +17,27 @@ mod screenshot;
mod input;
mod windows_mgmt;
fn ts() -> String {
Local::now().format("%H:%M:%S").to_string()
}
macro_rules! log_info {
($($arg:tt)*) => { println!("[{}] {}", ts(), format!($($arg)*)) };
}
macro_rules! log_cmd {
($($arg:tt)*) => { println!("[{}] [CMD] {}", ts(), format!($($arg)*)) };
}
macro_rules! log_ok {
($($arg:tt)*) => { println!("[{}] [OK] {}", ts(), format!($($arg)*)) };
}
macro_rules! log_err {
($($arg:tt)*) => { println!("[{}] [ERR] {}", ts(), format!($($arg)*)) };
}
#[derive(Debug, Serialize, Deserialize)]
struct Config {
relay_url: String,
relay_code: String,
api_key: String,
label: Option<String>,
}
@ -28,19 +46,19 @@ impl Config {
let base = dirs::config_dir()
.or_else(|| dirs::home_dir())
.unwrap_or_else(|| PathBuf::from("."));
base.join("helios-remote").join("config.json")
base.join("helios-remote").join("config.toml")
}
fn load() -> Option<Self> {
let path = Self::config_path();
let data = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&data).ok()
toml::from_str(&data).ok()
}
fn save(&self) -> std::io::Result<()> {
let path = Self::config_path();
std::fs::create_dir_all(path.parent().unwrap())?;
let data = serde_json::to_string_pretty(self).unwrap();
let data = toml::to_string_pretty(self).unwrap();
std::fs::write(&path, data)?;
Ok(())
}
@ -48,7 +66,7 @@ impl Config {
fn prompt_config() -> Config {
let relay_url = {
println!("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();
@ -59,46 +77,41 @@ fn prompt_config() -> Config {
}
};
let relay_code = {
println!("Enter relay code: ");
let api_key = {
print!("API Key: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
};
let label = {
println!("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();
if trimmed.is_empty() { None } else { Some(trimmed) }
};
Config { relay_url, relay_code, label }
Config { relay_url, api_key, label }
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "helios_client=info".to_string()),
)
.init();
println!("=== Helios Remote Client ===");
// Load or prompt for config
let config = match Config::load() {
Some(c) => {
info!("Loaded config from {:?}", Config::config_path());
log_info!("Config loaded from {:?}", Config::config_path());
c
}
None => {
info!("No config found — prompting for setup");
log_info!("No config found — first-time setup");
let c = prompt_config();
if let Err(e) = c.save() {
error!("Failed to save config: {e}");
log_err!("Failed to save config: {e}");
} else {
info!("Config saved to {:?}", Config::config_path());
log_info!("Config saved to {:?}", Config::config_path());
}
c
}
@ -112,7 +125,7 @@ async fn main() {
const MAX_BACKOFF: Duration = Duration::from_secs(30);
loop {
info!("Connecting to {}", config.relay_url);
log_info!("Connecting to {}...", config.relay_url);
// Build TLS connector - accepts self-signed certs for internal CA (Caddy tls internal)
let tls_connector = TlsConnector::builder()
.danger_accept_invalid_certs(true)
@ -121,7 +134,7 @@ async fn main() {
let connector = Connector::NativeTls(tls_connector);
match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await {
Ok((ws_stream, _)) => {
info!("Connected!");
log_info!("Connected!");
backoff = Duration::from_secs(1); // reset on success
let (mut write, mut read) = ws_stream.split();
@ -132,7 +145,7 @@ async fn main() {
};
let hello_json = serde_json::to_string(&hello).unwrap();
if let Err(e) = write.send(Message::Text(hello_json)).await {
error!("Failed to send Hello: {e}");
log_err!("Failed to send Hello: {e}");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
continue;
@ -148,7 +161,7 @@ async fn main() {
let server_msg: ServerMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
warn!("Failed to parse server message: {e}\nRaw: {text}");
log_err!("Failed to parse server message: {e} | raw: {text}");
continue;
}
};
@ -161,7 +174,7 @@ async fn main() {
let json = serde_json::to_string(&response).unwrap();
let mut w = write_clone.lock().await;
if let Err(e) = w.send(Message::Text(json)).await {
error!("Failed to send response: {e}");
log_err!("Failed to send response: {e}");
}
});
}
@ -170,11 +183,11 @@ async fn main() {
let _ = w.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
info!("Server closed connection");
log_info!("Server closed connection");
break;
}
Err(e) => {
error!("WebSocket error: {e}");
log_err!("WebSocket error: {e}");
break;
}
_ => {}
@ -184,7 +197,7 @@ async fn main() {
warn!("Disconnected. Reconnecting in {:?}...", backoff);
}
Err(e) => {
error!("Connection failed: {e}");
log_err!("Connection failed: {e}");
}
}
@ -199,15 +212,19 @@ async fn handle_message(
) -> ClientMessage {
match msg {
ServerMessage::ScreenshotRequest { request_id } => {
log_cmd!("screenshot");
match screenshot::take_screenshot() {
Ok((image_base64, width, height)) => ClientMessage::ScreenshotResponse {
request_id,
image_base64,
width,
height,
},
Ok((image_base64, width, height)) => {
log_ok!("screenshot taken ({}x{})", width, height);
ClientMessage::ScreenshotResponse {
request_id,
image_base64,
width,
height,
}
}
Err(e) => {
error!("Screenshot failed: {e}");
log_err!("Screenshot failed: {e}");
ClientMessage::Error {
request_id,
message: format!("Screenshot failed: {e}"),
@ -217,17 +234,20 @@ async fn handle_message(
}
ServerMessage::ExecRequest { request_id, command } => {
info!("Exec: {command}");
log_cmd!("exec: {command}");
let mut sh = shell.lock().await;
match sh.run(&command).await {
Ok((stdout, stderr, exit_code)) => ClientMessage::ExecResponse {
request_id,
stdout,
stderr,
exit_code,
},
Ok((stdout, stderr, exit_code)) => {
log_ok!("exec completed (exit {})", exit_code);
ClientMessage::ExecResponse {
request_id,
stdout,
stderr,
exit_code,
}
}
Err(e) => {
error!("Exec failed for command {:?}: {e}", command);
log_err!("exec failed for {:?}: {e}", command);
ClientMessage::Error {
request_id,
message: format!(
@ -240,11 +260,14 @@ async fn handle_message(
}
ServerMessage::ClickRequest { request_id, x, y, button } => {
info!("Click: ({x},{y}) {:?}", button);
log_cmd!("click {} {} {:?}", x, y, button);
match input::click(x, y, &button) {
Ok(()) => ClientMessage::Ack { request_id },
Ok(()) => {
log_ok!("click done");
ClientMessage::Ack { request_id }
}
Err(e) => {
error!("Click failed at ({x},{y}): {e}");
log_err!("click failed at ({x},{y}): {e}");
ClientMessage::Error {
request_id,
message: format!("Click at ({x},{y}) failed: {e}"),
@ -254,11 +277,14 @@ async fn handle_message(
}
ServerMessage::TypeRequest { request_id, text } => {
info!("Type: {} chars", text.len());
log_cmd!("type: {} chars", text.len());
match input::type_text(&text) {
Ok(()) => ClientMessage::Ack { request_id },
Ok(()) => {
log_ok!("type done");
ClientMessage::Ack { request_id }
}
Err(e) => {
error!("Type failed: {e}");
log_err!("type failed: {e}");
ClientMessage::Error {
request_id,
message: format!("Type failed: {e}"),
@ -268,59 +294,68 @@ async fn handle_message(
}
ServerMessage::ListWindowsRequest { request_id } => {
info!("ListWindows");
log_cmd!("list-windows");
match windows_mgmt::list_windows() {
Ok(windows) => ClientMessage::ListWindowsResponse { request_id, windows },
Ok(windows) => {
log_ok!("list-windows: {} windows", windows.len());
ClientMessage::ListWindowsResponse { request_id, windows }
}
Err(e) => {
error!("ListWindows failed: {e}");
log_err!("list-windows failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::MinimizeAllRequest { request_id } => {
info!("MinimizeAll");
log_cmd!("minimize-all");
match windows_mgmt::minimize_all() {
Ok(()) => ClientMessage::Ack { request_id },
Ok(()) => {
log_ok!("minimize-all done");
ClientMessage::Ack { request_id }
}
Err(e) => {
error!("MinimizeAll failed: {e}");
log_err!("minimize-all failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::FocusWindowRequest { request_id, window_id } => {
info!("FocusWindow: {window_id}");
log_cmd!("focus-window: {window_id}");
match windows_mgmt::focus_window(window_id) {
Ok(()) => ClientMessage::Ack { request_id },
Ok(()) => {
log_ok!("focus-window done");
ClientMessage::Ack { request_id }
}
Err(e) => {
error!("FocusWindow failed: {e}");
log_err!("focus-window failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => {
info!("MaximizeAndFocus: {window_id}");
log_cmd!("maximize-and-focus: {window_id}");
match windows_mgmt::maximize_and_focus(window_id) {
Ok(()) => ClientMessage::Ack { request_id },
Ok(()) => {
log_ok!("maximize-and-focus done");
ClientMessage::Ack { request_id }
}
Err(e) => {
error!("MaximizeAndFocus failed: {e}");
log_err!("maximize-and-focus failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::Ack { request_id } => {
info!("Server ack for {request_id}");
// Nothing to do - server acked something we sent
ClientMessage::Ack { request_id }
}
ServerMessage::Error { request_id, message } => {
error!("Server error (req={request_id:?}): {message}");
// No meaningful response needed but we need to return something
// Use a dummy ack if we have a request_id
log_err!("server error (req={request_id:?}): {message}");
if let Some(rid) = request_id {
ClientMessage::Ack { request_id: rid }
} else {

View file

@ -10,10 +10,10 @@
$ErrorActionPreference = "Stop"
$url = "https://github.com/agent-helios/helios-remote/releases/latest/download/helios-remote-client-windows.exe"
$dest = "$env:TEMP\helios-remote.exe"
$dest = "$env:USERPROFILE\Desktop\Helios Remote.exe"
Write-Host "Downloading helios-remote client..."
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
Write-Host "Starting..."
Start-Process -FilePath $dest -NoNewWindow
Start-Process -FilePath $dest