feat: rewrite remote.py as Rust CLI binary (crates/cli)
This commit is contained in:
parent
ba3b365f4e
commit
98b6fabef6
6 changed files with 815 additions and 45 deletions
|
|
@ -3,5 +3,6 @@ members = [
|
||||||
"crates/common",
|
"crates/common",
|
||||||
"crates/server",
|
"crates/server",
|
||||||
"crates/client",
|
"crates/client",
|
||||||
|
"crates/cli",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
|
||||||
44
README.md
44
README.md
|
|
@ -31,40 +31,40 @@ irm https://raw.githubusercontent.com/agent-helios/helios-remote/master/scripts/
|
||||||
```
|
```
|
||||||
AI Agent
|
AI Agent
|
||||||
│
|
│
|
||||||
▼ remote.py CLI
|
▼ helios CLI
|
||||||
helios-server ──WebSocket── helios-client (Windows)
|
helios-server ──WebSocket── helios-client (Windows)
|
||||||
```
|
```
|
||||||
|
|
||||||
1. The **Windows client** connects to the relay server via WebSocket and registers with its device label.
|
1. The **Windows client** connects to the relay server via WebSocket and registers with its device label.
|
||||||
2. The **AI agent** uses `remote.py` to issue commands — screenshots, shell commands, window management, file transfers.
|
2. The **AI agent** uses `helios` to issue commands — screenshots, shell commands, window management, file transfers.
|
||||||
3. The relay server forwards everything to the correct client and streams back responses.
|
3. The relay server forwards everything to the correct client and streams back responses.
|
||||||
|
|
||||||
Device labels are the sole identifier. Only one client instance can run per device.
|
Device labels are the sole identifier. Only one client instance can run per device.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## remote.py CLI
|
## helios CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python remote.py devices # list connected devices
|
helios devices # list connected devices
|
||||||
python remote.py screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
|
helios screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
|
||||||
python remote.py screenshot <device> <window_label> # screenshot a specific window
|
helios screenshot <device> <window_label> # screenshot a specific window
|
||||||
python remote.py exec <device> <command...> # run shell command (PowerShell)
|
helios exec <device> <command...> # run shell command (PowerShell)
|
||||||
python remote.py exec <device> --timeout 600 <command...> # with custom timeout (seconds)
|
helios exec <device> --timeout 600 <command...> # with custom timeout (seconds)
|
||||||
python remote.py windows <device> # list visible windows
|
helios windows <device> # list visible windows
|
||||||
python remote.py focus <device> <window_label> # focus a window
|
helios focus <device> <window_label> # focus a window
|
||||||
python remote.py maximize <device> <window_label> # maximize and focus a window
|
helios maximize <device> <window_label> # maximize and focus a window
|
||||||
python remote.py minimize-all <device> # minimize all windows
|
helios minimize-all <device> # minimize all windows
|
||||||
python remote.py prompt <device> "Please click Save" # show MessageBox, blocks until user confirms
|
helios prompt <device> "Please click Save" # show MessageBox, blocks until user confirms
|
||||||
python remote.py prompt <device> "message" --title "Title" # with custom dialog title
|
helios prompt <device> "message" --title "Title" # with custom dialog title
|
||||||
python remote.py run <device> <program> [args...] # launch program (fire-and-forget)
|
helios run <device> <program> [args...] # launch program (fire-and-forget)
|
||||||
python remote.py clipboard-get <device> # get clipboard text
|
helios clipboard-get <device> # get clipboard text
|
||||||
python remote.py clipboard-set <device> <text> # set clipboard text
|
helios clipboard-set <device> <text> # set clipboard text
|
||||||
python remote.py upload <device> <local> <remote> # upload file to device
|
helios upload <device> <local> <remote> # upload file to device
|
||||||
python remote.py download <device> <remote> <local> # download file from device
|
helios download <device> <remote> <local> # download file from device
|
||||||
python remote.py version <device> # compare relay/remote.py/client commits
|
helios version <device> # compare relay/helios/client commits
|
||||||
python remote.py logs <device> # fetch last 100 lines of client log
|
helios logs <device> # fetch last 100 lines of client log
|
||||||
python remote.py logs <device> --lines 200 # custom line count
|
helios logs <device> --lines 200 # custom line count
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
46
SKILL.md
46
SKILL.md
|
|
@ -15,7 +15,7 @@ When Moritz asks to do something on a connected PC:
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
- **Script:** `skills/helios-remote/remote.py`
|
- **Script:** `skills/helios-remote/helios`
|
||||||
- **Config:** `skills/helios-remote/config.env` (URL + API key, don't modify)
|
- **Config:** `skills/helios-remote/config.env` (URL + API key, don't modify)
|
||||||
- `SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote`
|
- `SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote`
|
||||||
|
|
||||||
|
|
@ -31,54 +31,54 @@ When Moritz asks to do something on a connected PC:
|
||||||
SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote
|
SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote
|
||||||
|
|
||||||
# List connected devices
|
# List connected devices
|
||||||
python $SKILL_DIR/remote.py devices
|
$SKILL_DIR/helios devices
|
||||||
|
|
||||||
# Screenshot → /tmp/helios-remote-screenshot.png
|
# Screenshot → /tmp/helios-remote-screenshot.png
|
||||||
# ALWAYS prefer window screenshots (saves bandwidth)!
|
# ALWAYS prefer window screenshots (saves bandwidth)!
|
||||||
python $SKILL_DIR/remote.py screenshot moritz-pc chrome # window by label
|
$SKILL_DIR/helios screenshot moritz-pc chrome # window by label
|
||||||
python $SKILL_DIR/remote.py screenshot moritz-pc screen # full screen only when no window known
|
$SKILL_DIR/helios screenshot moritz-pc screen # full screen only when no window known
|
||||||
|
|
||||||
# List visible windows (use labels for screenshot/focus/maximize)
|
# List visible windows (use labels for screenshot/focus/maximize)
|
||||||
python $SKILL_DIR/remote.py windows moritz-pc
|
$SKILL_DIR/helios windows moritz-pc
|
||||||
|
|
||||||
# Window labels come from the process name (e.g. chrome, discord, pycharm64)
|
# Window labels come from the process name (e.g. chrome, discord, pycharm64)
|
||||||
# Duplicates get a number suffix: chrome, chrome2, chrome3
|
# Duplicates get a number suffix: chrome, chrome2, chrome3
|
||||||
# Use `windows` to discover labels before targeting a specific window
|
# Use `windows` to discover labels before targeting a specific window
|
||||||
|
|
||||||
# Focus / maximize a window
|
# Focus / maximize a window
|
||||||
python $SKILL_DIR/remote.py focus moritz-pc discord
|
$SKILL_DIR/helios focus moritz-pc discord
|
||||||
python $SKILL_DIR/remote.py maximize moritz-pc chrome
|
$SKILL_DIR/helios maximize moritz-pc chrome
|
||||||
|
|
||||||
# Minimize all windows
|
# Minimize all windows
|
||||||
python $SKILL_DIR/remote.py minimize-all moritz-pc
|
$SKILL_DIR/helios minimize-all moritz-pc
|
||||||
|
|
||||||
# Shell command (PowerShell, no wrapper needed)
|
# Shell command (PowerShell, no wrapper needed)
|
||||||
python $SKILL_DIR/remote.py exec moritz-pc "Get-Process"
|
$SKILL_DIR/helios exec moritz-pc "Get-Process"
|
||||||
python $SKILL_DIR/remote.py exec moritz-pc "hostname"
|
$SKILL_DIR/helios exec moritz-pc "hostname"
|
||||||
# With longer timeout for downloads etc. (default: 30s)
|
# With longer timeout for downloads etc. (default: 30s)
|
||||||
python $SKILL_DIR/remote.py exec moritz-pc --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip"
|
$SKILL_DIR/helios exec moritz-pc --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip"
|
||||||
|
|
||||||
# Launch program (fire-and-forget)
|
# Launch program (fire-and-forget)
|
||||||
python $SKILL_DIR/remote.py run moritz-pc notepad.exe
|
$SKILL_DIR/helios run moritz-pc notepad.exe
|
||||||
|
|
||||||
# Ask user to do something (shows MessageBox, blocks until OK)
|
# Ask user to do something (shows MessageBox, blocks until OK)
|
||||||
python $SKILL_DIR/remote.py prompt moritz-pc "Please click Save, then OK"
|
$SKILL_DIR/helios prompt moritz-pc "Please click Save, then OK"
|
||||||
python $SKILL_DIR/remote.py prompt moritz-pc "UAC dialog coming - please confirm" --title "Action required"
|
$SKILL_DIR/helios prompt moritz-pc "UAC dialog coming - please confirm" --title "Action required"
|
||||||
|
|
||||||
# Clipboard
|
# Clipboard
|
||||||
python $SKILL_DIR/remote.py clipboard-get moritz-pc
|
$SKILL_DIR/helios clipboard-get moritz-pc
|
||||||
python $SKILL_DIR/remote.py clipboard-set moritz-pc "Text for clipboard"
|
$SKILL_DIR/helios clipboard-set moritz-pc "Text for clipboard"
|
||||||
|
|
||||||
# File transfer
|
# File transfer
|
||||||
python $SKILL_DIR/remote.py upload moritz-pc /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt"
|
$SKILL_DIR/helios upload moritz-pc /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt"
|
||||||
python $SKILL_DIR/remote.py download moritz-pc "C:\Users\Moritz\file.txt" /tmp/downloaded.txt
|
$SKILL_DIR/helios download moritz-pc "C:\Users\Moritz\file.txt" /tmp/downloaded.txt
|
||||||
|
|
||||||
# Version: compare relay + remote.py + client commits (are they in sync?)
|
# Version: compare relay + helios + client commits (are they in sync?)
|
||||||
python $SKILL_DIR/remote.py version moritz-pc
|
$SKILL_DIR/helios version moritz-pc
|
||||||
|
|
||||||
# Client log (last 100 lines, --lines for more)
|
# Client log (last 100 lines, --lines for more)
|
||||||
python $SKILL_DIR/remote.py logs moritz-pc
|
$SKILL_DIR/helios logs moritz-pc
|
||||||
python $SKILL_DIR/remote.py logs moritz-pc --lines 200
|
$SKILL_DIR/helios logs moritz-pc --lines 200
|
||||||
```
|
```
|
||||||
|
|
||||||
## Typical Workflow: UI Task
|
## Typical Workflow: UI Task
|
||||||
|
|
@ -94,7 +94,7 @@ python $SKILL_DIR/remote.py logs moritz-pc --lines 200
|
||||||
**Never interact with UI blindly.** When you need the user to click something:
|
**Never interact with UI blindly.** When you need the user to click something:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python $SKILL_DIR/remote.py prompt moritz-pc "Please click [Save], then press OK"
|
$SKILL_DIR/helios prompt moritz-pc "Please click [Save], then press OK"
|
||||||
```
|
```
|
||||||
|
|
||||||
This blocks until the user confirms. Use it whenever manual interaction is needed.
|
This blocks until the user confirms. Use it whenever manual interaction is needed.
|
||||||
|
|
|
||||||
16
crates/cli/Cargo.toml
Normal file
16
crates/cli/Cargo.toml
Normal 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
11
crates/cli/build.rs
Normal 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
742
crates/cli/src/main.rs
Normal 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(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue