feat: rewrite remote.py as Rust CLI binary (crates/cli)

This commit is contained in:
Helios 2026-03-06 03:02:22 +01:00
parent ba3b365f4e
commit 98b6fabef6
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
6 changed files with 815 additions and 45 deletions

16
crates/cli/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "helios-cli"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "helios"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["blocking", "json"] }
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
urlencoding = "2"

11
crates/cli/build.rs Normal file
View file

@ -0,0 +1,11 @@
fn main() {
// Embed git commit hash at build time
let output = std::process::Command::new("git")
.args(["log", "-1", "--format=%h"])
.output();
let commit = match output {
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
Err(_) => "unknown".to_string(),
};
println!("cargo:rustc-env=GIT_COMMIT={commit}");
}

742
crates/cli/src/main.rs Normal file
View file

@ -0,0 +1,742 @@
use base64::Engine;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process;
const GIT_COMMIT: &str = env!("GIT_COMMIT");
// ── Config ──────────────────────────────────────────────────────────────────
struct Config {
base_url: String,
api_key: String,
}
fn load_config() -> Config {
let exe = std::env::current_exe().unwrap_or_default();
let dir = exe.parent().unwrap_or(Path::new("."));
let path = dir.join("config.env");
let mut map = HashMap::new();
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string());
}
}
}
let base_url = map
.get("HELIOS_REMOTE_URL")
.cloned()
.unwrap_or_default()
.trim_end_matches('/')
.to_string();
let api_key = map
.get("HELIOS_REMOTE_API_KEY")
.cloned()
.unwrap_or_default();
if base_url.is_empty() || api_key.is_empty() {
eprintln!(
"[helios-remote] ERROR: config.env missing or incomplete at {}",
path.display()
);
process::exit(1);
}
Config { base_url, api_key }
}
// ── HTTP helpers ────────────────────────────────────────────────────────────
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::new()
}
fn req(
cfg: &Config,
method: &str,
path: &str,
body: Option<Value>,
timeout_secs: u64,
) -> Value {
let url = format!("{}{}", cfg.base_url, path);
let c = client();
let timeout = std::time::Duration::from_secs(timeout_secs);
let builder = match method {
"GET" => c.get(&url),
"POST" => c.post(&url),
_ => c.get(&url),
};
let builder = builder
.header("X-Api-Key", &cfg.api_key)
.header("Content-Type", "application/json")
.timeout(timeout);
let builder = if let Some(b) = body {
builder.body(b.to_string())
} else {
builder
};
let resp = match builder.send() {
Ok(r) => r,
Err(e) => {
if e.is_timeout() {
eprintln!(
"[helios-remote] TIMEOUT: {} did not respond within {} s",
url, timeout_secs
);
} else {
eprintln!(
"[helios-remote] CONNECTION ERROR: Cannot reach {}\n → {}",
url, e
);
}
process::exit(1);
}
};
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().unwrap_or_default();
let body_preview = if body_text.len() > 1000 {
&body_text[..1000]
} else {
&body_text
};
eprintln!(
"[helios-remote] HTTP {} {}\n URL : {}\n Method : {}\n Body : {}",
status.as_u16(),
status.canonical_reason().unwrap_or(""),
url,
method,
body_preview,
);
process::exit(1);
}
resp.json::<Value>().unwrap_or(json!({}))
}
// ── Label validation ────────────────────────────────────────────────────────
fn validate_label(label: &str) {
let valid = !label.is_empty()
&& label
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
&& label.chars().next().unwrap().is_ascii_alphanumeric();
if !valid {
eprintln!(
"[helios-remote] Invalid label '{}'. Must be lowercase, no whitespace, only a-z 0-9 - _",
label
);
process::exit(1);
}
}
// ── Window resolution ───────────────────────────────────────────────────────
fn resolve_window(cfg: &Config, device: &str, window_label: &str) -> i64 {
let data = req(cfg, "GET", &format!("/devices/{}/windows", device), None, 30);
let windows = data["windows"].as_array().cloned().unwrap_or_default();
let query = window_label.to_lowercase();
// Exact label match
for w in &windows {
if w["visible"].as_bool() == Some(true) && w["label"].as_str() == Some(&query) {
return w["id"].as_i64().unwrap();
}
}
// Substring match on label
let mut matches: Vec<&Value> = windows
.iter()
.filter(|w| {
w["visible"].as_bool() == Some(true)
&& w["label"]
.as_str()
.map(|l| l.contains(&query))
.unwrap_or(false)
})
.collect();
// Fallback: substring on title
if matches.is_empty() {
matches = windows
.iter()
.filter(|w| {
w["visible"].as_bool() == Some(true)
&& w["title"]
.as_str()
.map(|t| t.to_lowercase().contains(&query))
.unwrap_or(false)
})
.collect();
}
if matches.is_empty() {
eprintln!(
"[helios-remote] No visible window matching '{}'",
window_label
);
process::exit(1);
}
if matches.len() > 1 {
println!(
"[helios-remote] Multiple matches for '{}', using first:",
window_label
);
for w in &matches {
println!(
" {:<30} {}",
w["label"].as_str().unwrap_or("?"),
w["title"].as_str().unwrap_or("")
);
}
}
matches[0]["id"].as_i64().unwrap()
}
// ── CLI ─────────────────────────────────────────────────────────────────────
#[derive(Parser)]
#[command(
name = "helios",
about = "Control devices connected to the Helios Remote Relay Server."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// List all connected devices
Devices,
/// Capture screenshot (screen or window label)
Screenshot {
/// Device label
device: String,
/// 'screen' for full screen, or a window label
target: String,
},
/// Run a shell command on the remote device
Exec {
/// Device label
device: String,
/// Timeout in seconds
#[arg(long)]
timeout: Option<u64>,
/// Command (and arguments) to execute
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
parts: Vec<String>,
},
/// List all visible windows on the remote device
Windows {
/// Device label
device: String,
},
/// Bring a window to the foreground
Focus {
/// Device label
device: String,
/// Window label
window: String,
},
/// Maximize and focus a window
Maximize {
/// Device label
device: String,
/// Window label
window: String,
},
/// Minimize all windows
MinimizeAll {
/// Device label
device: String,
},
/// Show a MessageBox asking the user to do something
Prompt {
/// Device label
device: String,
/// Message to display
message: String,
/// Custom dialog title
#[arg(long)]
title: Option<String>,
},
/// Launch a program (fire-and-forget)
Run {
/// Device label
device: String,
/// Program to launch
program: String,
/// Program arguments
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Get clipboard contents
ClipboardGet {
/// Device label
device: String,
},
/// Set clipboard contents
ClipboardSet {
/// Device label
device: String,
/// Text to set
text: String,
},
/// Upload a local file to the remote device
Upload {
/// Device label
device: String,
/// Local file path
local_path: PathBuf,
/// Remote file path
remote_path: String,
},
/// Download a file from the remote device
Download {
/// Device label
device: String,
/// Remote file path
remote_path: String,
/// Local file path
local_path: PathBuf,
},
/// Compare relay, CLI, and client commits
Version {
/// Device label
device: String,
},
/// Fetch last N lines of client log file
Logs {
/// Device label
device: String,
/// Number of lines
#[arg(long, default_value = "100")]
lines: u32,
},
}
fn main() {
let cli = Cli::parse();
let cfg = load_config();
match cli.command {
Commands::Devices => {
let data = req(&cfg, "GET", "/devices", None, 30);
let devices = data["devices"].as_array();
match devices {
Some(devs) if !devs.is_empty() => {
println!("{:<30}", "Device");
println!("{}", "-".repeat(30));
for d in devs {
println!("{}", d["label"].as_str().unwrap_or("?"));
}
}
_ => println!("No devices connected."),
}
}
Commands::Screenshot { device, target } => {
validate_label(&device);
let out_path = Path::new("/tmp/helios-remote-screenshot.png");
let data = if target == "screen" {
req(
&cfg,
"POST",
&format!("/devices/{}/screenshot", device),
None,
30,
)
} else {
let wid = resolve_window(&cfg, &device, &target);
req(
&cfg,
"POST",
&format!("/devices/{}/windows/{}/screenshot", device, wid),
None,
30,
)
};
let b64 = data["image_base64"]
.as_str()
.or(data["screenshot"].as_str())
.or(data["image"].as_str())
.or(data["data"].as_str())
.or(data["png"].as_str());
let b64 = match b64 {
Some(s) => s,
None => {
eprintln!("[helios-remote] ERROR: No image in response.");
process::exit(1);
}
};
let b64 = if let Some((_, after)) = b64.split_once(',') {
after
} else {
b64
};
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap_or_else(|e| {
eprintln!("[helios-remote] ERROR: Failed to decode base64: {}", e);
process::exit(1);
});
std::fs::write(out_path, &bytes).unwrap_or_else(|e| {
eprintln!("[helios-remote] ERROR: Failed to write screenshot: {}", e);
process::exit(1);
});
println!("{}", out_path.display());
}
Commands::Exec {
device,
timeout,
parts,
} => {
validate_label(&device);
let command = parts.join(" ");
let mut body = json!({"command": command});
if let Some(t) = timeout {
body["timeout_ms"] = json!(t * 1000);
}
let http_timeout = timeout.unwrap_or(30) + 5;
let data = req(
&cfg,
"POST",
&format!("/devices/{}/exec", device),
Some(body),
http_timeout.max(35),
);
let stdout = data["stdout"]
.as_str()
.or(data["output"].as_str())
.unwrap_or("");
let stderr = data["stderr"].as_str().unwrap_or("");
let exit_code = data["exit_code"].as_i64();
if !stdout.is_empty() {
if stdout.ends_with('\n') {
print!("{}", stdout);
} else {
println!("{}", stdout);
}
}
if !stderr.is_empty() {
eprintln!("[stderr] {}", stderr);
}
if let Some(code) = exit_code {
if code != 0 {
eprintln!("[helios-remote] Command exited with code {}", code);
process::exit(code as i32);
}
}
}
Commands::Windows { device } => {
validate_label(&device);
let data = req(
&cfg,
"GET",
&format!("/devices/{}/windows", device),
None,
30,
);
let windows = data["windows"].as_array().cloned().unwrap_or_default();
let visible: Vec<&Value> = windows
.iter()
.filter(|w| w["visible"].as_bool() == Some(true))
.collect();
if visible.is_empty() {
println!("No windows returned.");
return;
}
println!("{:<30} Title", "Label");
println!("{}", "-".repeat(70));
for w in visible {
println!(
"{:<30} {}",
w["label"].as_str().unwrap_or("?"),
w["title"].as_str().unwrap_or("")
);
}
}
Commands::Focus { device, window } => {
validate_label(&device);
let wid = resolve_window(&cfg, &device, &window);
req(
&cfg,
"POST",
&format!("/devices/{}/windows/{}/focus", device, wid),
None,
30,
);
println!("Window '{}' focused on {}.", window, device);
}
Commands::Maximize { device, window } => {
validate_label(&device);
let wid = resolve_window(&cfg, &device, &window);
req(
&cfg,
"POST",
&format!("/devices/{}/windows/{}/maximize", device, wid),
None,
30,
);
println!("Window '{}' maximized on {}.", window, device);
}
Commands::MinimizeAll { device } => {
validate_label(&device);
req(
&cfg,
"POST",
&format!("/devices/{}/windows/minimize-all", device),
None,
30,
);
println!("All windows minimized on {}.", device);
}
Commands::Prompt {
device,
message,
title,
} => {
validate_label(&device);
let mut body = json!({"message": message});
if let Some(t) = title {
body["title"] = json!(t);
}
let data = req(
&cfg,
"POST",
&format!("/devices/{}/prompt", device),
Some(body),
30,
);
let answer = data["answer"].as_str().unwrap_or("");
if !answer.is_empty() {
println!("{}", answer);
} else {
println!("Prompt confirmed.");
}
}
Commands::Run {
device,
program,
args,
} => {
validate_label(&device);
req(
&cfg,
"POST",
&format!("/devices/{}/run", device),
Some(json!({"program": program, "args": args})),
30,
);
println!("Started {:?} on {}.", program, device);
}
Commands::ClipboardGet { device } => {
validate_label(&device);
let data = req(
&cfg,
"GET",
&format!("/devices/{}/clipboard", device),
None,
30,
);
println!("{}", data["text"].as_str().unwrap_or(""));
}
Commands::ClipboardSet { device, text } => {
validate_label(&device);
req(
&cfg,
"POST",
&format!("/devices/{}/clipboard", device),
Some(json!({"text": text})),
30,
);
println!("Clipboard set ({} chars) on {}.", text.len(), device);
}
Commands::Upload {
device,
local_path,
remote_path,
} => {
validate_label(&device);
if !local_path.exists() {
eprintln!(
"[helios-remote] ERROR: Local file not found: {}",
local_path.display()
);
process::exit(1);
}
let bytes = std::fs::read(&local_path).unwrap_or_else(|e| {
eprintln!("[helios-remote] ERROR: Failed to read file: {}", e);
process::exit(1);
});
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
req(
&cfg,
"POST",
&format!("/devices/{}/upload", device),
Some(json!({"path": remote_path, "content_base64": b64})),
30,
);
println!(
"Uploaded {} → {} on {}.",
local_path.display(),
remote_path,
device
);
}
Commands::Download {
device,
remote_path,
local_path,
} => {
validate_label(&device);
let encoded = urlencoding::encode(&remote_path);
let data = req(
&cfg,
"GET",
&format!("/devices/{}/download?path={}", device, encoded),
None,
30,
);
let b64 = data["content_base64"].as_str().unwrap_or("");
if b64.is_empty() {
eprintln!("[helios-remote] ERROR: No content in download response.");
process::exit(1);
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap_or_else(|e| {
eprintln!("[helios-remote] ERROR: Failed to decode base64: {}", e);
process::exit(1);
});
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&local_path, &bytes).unwrap_or_else(|e| {
eprintln!("[helios-remote] ERROR: Failed to write file: {}", e);
process::exit(1);
});
let size = data["size"].as_u64().unwrap_or(bytes.len() as u64);
println!(
"Downloaded {} → {} ({} bytes).",
remote_path,
local_path.display(),
size
);
}
Commands::Version { device } => {
validate_label(&device);
// Relay version
let relay_commit = match reqwest::blocking::get(&format!("{}/version", cfg.base_url)) {
Ok(r) => r
.json::<Value>()
.ok()
.and_then(|v| v["commit"].as_str().map(String::from))
.unwrap_or_else(|| "?".into()),
Err(e) => format!("{}", e),
};
// CLI commit
let cli_commit = GIT_COMMIT;
// Client version
let client_commit =
match std::panic::catch_unwind(|| {
req(&cfg, "GET", &format!("/devices/{}/version", device), None, 10)
}) {
Ok(data) => data["commit"]
.as_str()
.unwrap_or("?")
.to_string(),
Err(_) => "unreachable".to_string(),
};
let all_same = relay_commit == cli_commit && cli_commit == client_commit;
println!(" relay {}", relay_commit);
println!(" cli {}", cli_commit);
println!(" client {}", client_commit);
println!(
" {}",
if all_same {
"✅ all in sync"
} else {
"⚠️ OUT OF SYNC"
}
);
}
Commands::Logs { device, lines } => {
validate_label(&device);
let data = req(
&cfg,
"GET",
&format!("/devices/{}/logs?lines={}", device, lines),
None,
30,
);
if let Some(err) = data["error"].as_str() {
eprintln!("[helios-remote] {}", err);
process::exit(1);
}
println!(
"# {} (last {} lines)",
data["log_path"].as_str().unwrap_or("?"),
lines
);
println!("{}", data["content"].as_str().unwrap_or(""));
}
}
}