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, 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::().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, /// Command (and arguments) to execute #[arg(trailing_var_arg = true, allow_hyphen_values = true)] parts: Vec, }, /// 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, }, /// 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, }, /// 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::() .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("")); } } }