refactor: enforce device labels, unify screenshot, remove deprecated commands, session-id-less design

- Device labels: lowercase, no whitespace, only a-z 0-9 - _ (enforced at config time)
- Session IDs removed: device label is the sole identifier
- Routes changed: /sessions/:id → /devices/:label
- Removed commands: click, type, find-window, wait-for-window, label, old version, server-version
- Renamed: status → version (compares relay/remote.py/client commits)
- Unified screenshot: takes 'screen' or a window label as argument
- Windows listed with human-readable labels (same format as device labels)
- Single instance enforcement via PID lock file
- Removed input.rs (click/type functionality)
- All docs and code in English
- Protocol: Hello.label is now required (String, not Option<String>)
- Client auto-migrates invalid labels on startup
This commit is contained in:
Helios 2026-03-06 01:55:28 +01:00
parent 5fd01a423d
commit 0b4a6de8ae
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
14 changed files with 736 additions and 1180 deletions

View file

@ -24,6 +24,7 @@ base64 = "0.22"
png = "0.17"
futures-util = "0.3"
colored = "2"
scopeguard = "1"
terminal_size = "0.3"
unicode-width = "0.1"

View file

@ -1,36 +1,40 @@
# helios-client (Phase 2 — not yet implemented)
# helios-client
This crate will contain the Windows remote-control client for `helios-remote`.
Windows client for helios-remote. Connects to the relay server via WebSocket and executes commands.
## Planned Features
## Features
- Connects to the relay server via WebSocket (`wss://`)
- Sends a `Hello` message on connect with an optional display label
- Handles incoming `ServerMessage` commands:
- `ScreenshotRequest` → captures the primary display (Windows GDI or `windows-capture`) and responds with base64 PNG
- `ExecRequest` → runs a shell command in a persistent `cmd.exe` / PowerShell session and returns stdout/stderr/exit-code
- `ClickRequest` → simulates a mouse click via `SendInput` Win32 API
- `TypeRequest` → types text via `SendInput` (virtual key events)
- Persistent shell session so `cd C:\Users` persists across `exec` calls
- Auto-reconnect with exponential backoff
- Configurable via environment variables or a `client.toml` config file
- Full-screen and per-window screenshots
- Shell command execution (persistent PowerShell session)
- Window management (list, focus, maximize, minimize)
- File upload/download
- Clipboard get/set
- Program launch (fire-and-forget)
- User prompts (MessageBox)
- Single instance enforcement (PID lock file)
## Planned Tech Stack
## Configuration
| Crate | Purpose |
|---|---|
| `tokio` | Async runtime |
| `tokio-tungstenite` | WebSocket client |
| `serde_json` | Protocol serialization |
| `windows` / `winapi` | Screen capture, mouse/keyboard input |
| `base64` | PNG encoding for screenshots |
On first run, the client prompts for:
- **Relay URL** (default: `wss://remote.agent-helios.me/ws`)
- **API Key**
- **Device label** — must be lowercase, no whitespace, only `a-z 0-9 - _`
## Build Target
Config is saved to `%APPDATA%/helios-remote/config.toml`.
## Device Labels
The device label is the sole identifier for this machine. It must follow these rules:
- Lowercase only
- No whitespace
- Only characters: `a-z`, `0-9`, `-`, `_`
Examples: `moritz_pc`, `work-desktop`, `gaming-rig`
If an existing config has an invalid label, it will be automatically migrated on next startup.
## Build
```bash
cargo build -p helios-client --release
```
cargo build --target x86_64-pc-windows-gnu
```
## App Icon
The file `assets/logo.ico` in the repository root is the application icon intended for the Windows `.exe`. It can be embedded at compile time using a build script (e.g. via the `winres` crate).

View file

@ -1,154 +0,0 @@
/// Mouse click and keyboard input via Windows SendInput (or stub on non-Windows).
use helios_common::MouseButton;
#[cfg(windows)]
pub fn click(x: i32, y: i32, button: &MouseButton) -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN,
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEINPUT,
};
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
unsafe {
let screen_w = GetSystemMetrics(SM_CXSCREEN) as i32;
let screen_h = GetSystemMetrics(SM_CYSCREEN) as i32;
if screen_w == 0 || screen_h == 0 {
return Err(format!(
"Could not get screen dimensions: {screen_w}x{screen_h}"
));
}
// Convert pixel coords to absolute 0-65535 range
let abs_x = ((x * 65535) / screen_w) as i32;
let abs_y = ((y * 65535) / screen_h) as i32;
let (down_flag, up_flag) = match button {
MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
};
// Move to position
let move_input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
mi: MOUSEINPUT {
dx: abs_x,
dy: abs_y,
mouseData: 0,
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE,
time: 0,
dwExtraInfo: 0,
},
},
};
let down_input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
mi: MOUSEINPUT {
dx: abs_x,
dy: abs_y,
mouseData: 0,
dwFlags: down_flag | MOUSEEVENTF_ABSOLUTE,
time: 0,
dwExtraInfo: 0,
},
},
};
let up_input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
mi: MOUSEINPUT {
dx: abs_x,
dy: abs_y,
mouseData: 0,
dwFlags: up_flag | MOUSEEVENTF_ABSOLUTE,
time: 0,
dwExtraInfo: 0,
},
},
};
let inputs = [move_input, down_input, up_input];
let result = SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
if result != inputs.len() as u32 {
return Err(format!(
"SendInput for click at ({x},{y}) sent {result}/{} events — some may have been blocked by UIPI",
inputs.len()
));
}
Ok(())
}
}
#[cfg(windows)]
pub fn type_text(text: &str) -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_UNICODE,
};
if text.is_empty() {
return Ok(());
}
unsafe {
let mut inputs: Vec<INPUT> = Vec::with_capacity(text.len() * 2);
for ch in text.encode_utf16() {
// Key down
inputs.push(INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: ch,
dwFlags: KEYEVENTF_UNICODE,
time: 0,
dwExtraInfo: 0,
},
},
});
// Key up
inputs.push(INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: ch,
dwFlags: KEYEVENTF_UNICODE
| windows::Win32::UI::Input::KeyboardAndMouse::KEYEVENTF_KEYUP,
time: 0,
dwExtraInfo: 0,
},
},
});
}
let result = SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
if result != inputs.len() as u32 {
return Err(format!(
"SendInput for type_text sent {result}/{} events — some may have been blocked (UIPI or secure desktop)",
inputs.len()
));
}
Ok(())
}
}
#[cfg(not(windows))]
pub fn click(_x: i32, _y: i32, _button: &MouseButton) -> Result<(), String> {
Err("click() is only supported on Windows".to_string())
}
#[cfg(not(windows))]
pub fn type_text(_text: &str) -> Result<(), String> {
Err("type_text() is only supported on Windows".to_string())
}

View file

@ -11,27 +11,23 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con
use base64::Engine;
use helios_common::{ClientMessage, ServerMessage};
use uuid::Uuid;
use helios_common::protocol::{is_valid_label, sanitize_label};
mod display;
mod logger;
mod shell;
mod screenshot;
mod input;
mod windows_mgmt;
// Re-export trunc for use in this file
use display::trunc;
fn banner() {
println!();
// Use same column layout as info_line: 2sp + emoji_cell(2w) + 2sp + name(14) + 2sp + value
// ☀ is 1-wide → emoji_cell pads to 2 → need 1 extra space to match
println!(" {} {}", "".yellow().bold(), "HELIOS REMOTE".bold());
display::info_line("🔗", "commit:", &env!("GIT_COMMIT").dimmed().to_string());
}
fn print_session_info(label: &str, sid: &uuid::Uuid) {
fn print_device_info(label: &str) {
#[cfg(windows)]
{
let admin = is_admin();
@ -46,7 +42,6 @@ fn print_session_info(label: &str, sid: &uuid::Uuid) {
display::info_line("👤", "privileges:", &"no admin".dimmed().to_string());
display::info_line("🖥", "device:", &label.dimmed().to_string());
display::info_line("🪪", "session:", &sid.to_string().dimmed().to_string());
println!();
}
@ -72,14 +67,69 @@ fn enable_ansi() {
}
}
// ────────────────────────────────────────────────────────────────────────────
// ── Single instance enforcement ─────────────────────────────────────────────
fn lock_file_path() -> PathBuf {
let base = dirs::config_dir()
.or_else(|| dirs::home_dir())
.unwrap_or_else(|| PathBuf::from("."));
base.join("helios-remote").join("instance.lock")
}
/// Try to acquire a single-instance lock. Returns true if we got it.
fn acquire_instance_lock() -> bool {
let path = lock_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Check if another instance is running
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(pid) = content.trim().parse::<u32>() {
// Check if process is still alive
#[cfg(windows)]
{
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
let alive = unsafe {
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).is_ok()
};
if alive {
return false;
}
}
#[cfg(not(windows))]
{
use std::process::Command;
let alive = Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false);
if alive {
return false;
}
}
}
}
}
// Write our PID
let pid = std::process::id();
std::fs::write(&path, pid.to_string()).is_ok()
}
fn release_instance_lock() {
let _ = std::fs::remove_file(lock_file_path());
}
// ── Config ──────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Config {
relay_url: String,
api_key: String,
label: Option<String>,
session_id: Option<String>, // persistent UUID
label: String,
}
impl Config {
@ -131,39 +181,73 @@ fn prompt_config() -> Config {
};
let label = {
let default_label = hostname();
print!(" {} Label for this PC [{}]: ", "".cyan().bold(), default_label);
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
Some(default_label)
} else {
Some(trimmed)
let default_label = sanitize_label(&hostname());
loop {
print!(" {} Device label [{}]: ", "".cyan().bold(), default_label);
std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim();
let candidate = if trimmed.is_empty() {
default_label.clone()
} else {
trimmed.to_string()
};
if is_valid_label(&candidate) {
break candidate;
}
println!(" {} Label must be lowercase, no spaces. Only a-z, 0-9, '-', '_'.",
"".red().bold());
println!(" Suggestion: {}", sanitize_label(&candidate).cyan());
}
};
Config { relay_url, api_key, label, session_id: None }
Config { relay_url, api_key, label }
}
#[tokio::main]
async fn main() {
// Enable ANSI color codes on Windows (required when running as admin)
#[cfg(windows)]
enable_ansi();
logger::init();
// Suppress tracing output by default
if std::env::var("RUST_LOG").is_err() {
unsafe { std::env::set_var("RUST_LOG", "off"); }
}
banner();
// Single instance check
if !acquire_instance_lock() {
display::err("", "Another instance of helios-remote is already running.");
display::err("", "Only one instance per device is allowed.");
std::process::exit(1);
}
// Clean up lock on exit
let _guard = scopeguard::guard((), |_| release_instance_lock());
// Load or prompt for config
let config = match Config::load() {
Some(c) => c,
Some(c) => {
// Validate existing label
if !is_valid_label(&c.label) {
let new_label = sanitize_label(&c.label);
display::info_line("", "migrate:", &format!(
"Label '{}' is invalid, migrating to '{}'", c.label, new_label
));
let mut cfg = c;
cfg.label = new_label;
if let Err(e) = cfg.save() {
display::err("", &format!("Failed to save config: {e}"));
}
cfg
} else {
c
}
}
None => {
display::info_line("", "setup:", "No config found — first-time setup");
println!();
@ -178,22 +262,8 @@ async fn main() {
}
};
// Resolve or generate persistent session UUID
let sid: Uuid = match &config.session_id {
Some(id) => Uuid::parse_str(id).unwrap_or_else(|_| Uuid::new_v4()),
None => {
let id = Uuid::new_v4();
let mut cfg = config.clone();
cfg.session_id = Some(id.to_string());
if let Err(e) = cfg.save() {
display::err("", &format!("Failed to save session_id: {e}"));
}
id
}
};
let label = config.label.clone().unwrap_or_else(|| hostname());
print_session_info(&label, &sid);
let label = config.label.clone();
print_device_info(&label);
let config = Arc::new(config);
let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
@ -225,9 +295,9 @@ async fn main() {
let (mut write, mut read) = ws_stream.split();
// Send Hello
// Send Hello with device label
let hello = ClientMessage::Hello {
label: config.label.clone(),
label: label.clone(),
};
let hello_json = serde_json::to_string(&hello).unwrap();
if let Err(e) = write.send(Message::Text(hello_json)).await {
@ -254,9 +324,6 @@ async fn main() {
let shell_clone = Arc::clone(&shell);
tokio::spawn(async move {
// tokio isolates panics per task — a panic here won't kill
// the main loop. handle_message uses map_err everywhere so
// it should never panic in practice.
let response = handle_message(server_msg, shell_clone).await;
let json = match serde_json::to_string(&response) {
Ok(j) => j,
@ -343,14 +410,14 @@ async fn handle_message(
}
ServerMessage::ScreenshotRequest { request_id } => {
display::cmd_start("📷", "screenshot", "");
display::cmd_start("📷", "screenshot", "screen");
match screenshot::take_screenshot() {
Ok((image_base64, width, height)) => {
display::cmd_done("📷", "screenshot", "", true, &format!("{width}×{height}"));
display::cmd_done("📷", "screenshot", "screen", true, &format!("{width}×{height}"));
ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
}
Err(e) => {
display::cmd_done("📷", "screenshot", "", false, &format!("{e}"));
display::cmd_done("📷", "screenshot", "screen", false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") }
}
}
@ -358,7 +425,6 @@ async fn handle_message(
ServerMessage::PromptRequest { request_id, message, title: _ } => {
display::prompt_waiting(&message);
// Read user input from stdin (blocking)
let answer = tokio::task::spawn_blocking(|| {
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
@ -375,8 +441,6 @@ async fn handle_message(
match sh.run(&command, timeout_ms).await {
Ok((stdout, stderr, exit_code)) => {
let result = if exit_code != 0 {
// For errors: use first non-empty stderr line (actual error message),
// ignoring PowerShell boilerplate like "+ CategoryInfo", "In Zeile:", etc.
let err_line = stderr.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty()
@ -388,7 +452,6 @@ async fn handle_message(
.to_string();
err_line
} else {
// Success: first stdout line, no exit code
stdout.trim().lines().next().unwrap_or("").to_string()
};
display::cmd_done("", "execute", &payload, exit_code == 0, &result);
@ -401,36 +464,6 @@ async fn handle_message(
}
}
ServerMessage::ClickRequest { request_id, x, y, button } => {
let payload = format!("({x}, {y}) {button:?}");
display::cmd_start("🖱", "click", &payload);
match input::click(x, y, &button) {
Ok(()) => {
display::cmd_done("🖱", "click", &payload, true, "done");
ClientMessage::Ack { request_id }
}
Err(e) => {
display::cmd_done("🖱", "click", &payload, false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Click at ({x},{y}) failed: {e}") }
}
}
}
ServerMessage::TypeRequest { request_id, text } => {
let payload = format!("{} chars", text.len());
display::cmd_start("", "type", &payload);
match input::type_text(&text) {
Ok(()) => {
display::cmd_done("", "type", &payload, true, "done");
ClientMessage::Ack { request_id }
}
Err(e) => {
display::cmd_done("", "type", &payload, false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Type failed: {e}") }
}
}
}
ServerMessage::ListWindowsRequest { request_id } => {
display::cmd_start("🪟", "list windows", "");
match windows_mgmt::list_windows() {
@ -610,7 +643,7 @@ async fn handle_message(
if let Some(rid) = request_id {
ClientMessage::Ack { request_id: rid }
} else {
ClientMessage::Hello { label: None }
ClientMessage::Hello { label: String::new() }
}
}
}

View file

@ -1,4 +1,4 @@
use helios_common::protocol::WindowInfo;
use helios_common::protocol::{sanitize_label, WindowInfo};
// ── Windows implementation ──────────────────────────────────────────────────
@ -14,7 +14,6 @@ mod win_impl {
keybd_event, KEYEVENTF_KEYUP, VK_MENU,
};
// Collect HWNDs via EnumWindows callback
unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let list = &mut *(lparam.0 as *mut Vec<HWND>);
list.push(hwnd);
@ -38,19 +37,29 @@ mod win_impl {
String::from_utf16_lossy(&buf[..len as usize])
}
/// Generate a human-readable label from a window title.
/// E.g. "Google Chrome" -> "google_chrome", "Discord" -> "discord"
fn window_label(title: &str) -> String {
sanitize_label(title)
}
pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
let hwnds = get_all_hwnds();
let mut windows = Vec::new();
for hwnd in hwnds {
let visible = unsafe { IsWindowVisible(hwnd).as_bool() };
let title = hwnd_title(hwnd);
// Only return visible windows with a non-empty title
if !visible || title.is_empty() {
continue;
}
let label = window_label(&title);
if label.is_empty() {
continue;
}
windows.push(WindowInfo {
id: hwnd.0 as u64,
title,
label,
visible: true,
});
}
@ -71,9 +80,6 @@ mod win_impl {
Ok(())
}
/// Bypass Windows Focus Stealing Prevention by sending a fake Alt keypress
/// before calling SetForegroundWindow. Without this, SetForegroundWindow
/// silently fails when the calling thread is not in the foreground.
unsafe fn force_foreground(hwnd: HWND) {
keybd_event(VK_MENU.0 as u8, 0, Default::default(), 0);
keybd_event(VK_MENU.0 as u8, 0, KEYEVENTF_KEYUP, 0);