737 lines
22 KiB
Rust
737 lines
22 KiB
Rust
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 notification to the user (fire-and-forget, no response needed)
|
|
Inform {
|
|
/// 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 = "20")]
|
|
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::Inform {
|
|
device,
|
|
message,
|
|
title,
|
|
} => {
|
|
validate_label(&device);
|
|
let mut body = json!({"message": message});
|
|
if let Some(t) = title {
|
|
body["title"] = json!(t);
|
|
}
|
|
req(
|
|
&cfg,
|
|
"POST",
|
|
&format!("/devices/{}/inform", device),
|
|
Some(body),
|
|
10,
|
|
);
|
|
println!("User informed on {}.", device);
|
|
}
|
|
|
|
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(""));
|
|
}
|
|
}
|
|
}
|