Compare commits

..

No commits in common. "master" and "latest" have entirely different histories.

31 changed files with 850 additions and 2904 deletions

View file

@ -1,6 +0,0 @@
[target.x86_64-unknown-linux-gnu]
linker = "x86_64-linux-gnu-gcc"
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
ar = "x86_64-w64-mingw32-ar"

View file

@ -37,8 +37,8 @@ jobs:
with:
targets: x86_64-pc-windows-gnu
- name: Install MinGW cross-compiler and tools
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools
- name: Install MinGW cross-compiler
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
@ -47,7 +47,7 @@ jobs:
- name: Build Windows client (cross-compile)
run: |
cargo build --release --package helios-remote-client --target x86_64-pc-windows-gnu
cargo build --release --package helios-client --target x86_64-pc-windows-gnu
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
@ -55,12 +55,12 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: helios-remote-client-windows
path: target/x86_64-pc-windows-gnu/release/helios-remote-client.exe
path: target/x86_64-pc-windows-gnu/release/helios-client.exe
if-no-files-found: error
- name: Rename exe for release
if: github.ref == 'refs/heads/master'
run: cp target/x86_64-pc-windows-gnu/release/helios-remote-client.exe helios-remote-client-windows.exe
run: cp target/x86_64-pc-windows-gnu/release/helios-client.exe helios-remote-client-windows.exe
- name: Publish rolling release (latest)
if: github.ref == 'refs/heads/master'
@ -73,126 +73,3 @@ jobs:
files: helios-remote-client-windows.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy client to VPS
if: github.ref == 'refs/heads/master'
env:
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts
scp -i ~/.ssh/deploy_key helios-remote-client-windows.exe \
root@46.225.185.232:/var/www/helios-remote/helios-remote-client-windows.exe
deploy-relay:
runs-on: ubuntu-latest
needs: build-and-test
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- name: Install Rust + x86_64-linux target
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Install cross-linker
run: sudo apt-get update && sudo apt-get install -y gcc-x86-64-linux-gnu
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
with:
key: linux-x86_64
- name: Build relay (x86_64 Linux)
run: cargo build --release --package helios-remote-relay --target x86_64-unknown-linux-gnu
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc
- name: Deploy relay to VPS
env:
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts
# Only publish to download URL — relay updates itself when triggered by CLI
scp -i ~/.ssh/deploy_key \
target/x86_64-unknown-linux-gnu/release/helios-remote-relay \
root@46.225.185.232:/var/www/helios-remote/helios-remote-relay-linux
# Write version.json so CLI knows what's available
echo "{\"commit\":\"$(git rev-parse --short HEAD)\"}" > version.json
scp -i ~/.ssh/deploy_key version.json \
root@46.225.185.232:/var/www/helios-remote/version.json
build-cli:
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Install Rust (stable) + targets
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu,x86_64-pc-windows-gnu,aarch64-unknown-linux-gnu
- name: Install cross-compilers
run: sudo apt-get update && sudo apt-get install -y gcc-x86-64-linux-gnu gcc-mingw-w64-x86-64 mingw-w64-tools gcc-aarch64-linux-gnu
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
with:
key: cli
- name: Build CLI (Linux x86_64)
run: cargo build --release --package helios-remote-cli --target x86_64-unknown-linux-gnu
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc
- name: Build CLI (Linux aarch64)
run: cargo build --release --package helios-remote-cli --target aarch64-unknown-linux-gnu
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
- name: Build CLI (Windows x86_64)
run: cargo build --release --package helios-remote-cli --target x86_64-pc-windows-gnu
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
- name: Upload Linux CLI artifact
uses: actions/upload-artifact@v4
with:
name: helios-remote-cli-linux
path: target/x86_64-unknown-linux-gnu/release/helios-remote-cli
if-no-files-found: error
- name: Upload Windows CLI artifact
uses: actions/upload-artifact@v4
with:
name: helios-remote-cli-windows
path: target/x86_64-pc-windows-gnu/release/helios-remote-cli.exe
if-no-files-found: error
- name: Deploy CLI to VPS
if: github.ref == 'refs/heads/master'
env:
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts
scp -i ~/.ssh/deploy_key \
target/x86_64-unknown-linux-gnu/release/helios-remote-cli \
root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-linux
scp -i ~/.ssh/deploy_key \
target/aarch64-unknown-linux-gnu/release/helios-remote-cli \
root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-linux-aarch64
scp -i ~/.ssh/deploy_key \
target/x86_64-pc-windows-gnu/release/helios-remote-cli.exe \
root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-windows.exe

1
.gitignore vendored
View file

@ -3,4 +3,3 @@ Cargo.lock
**/*.rs.bk
.env
*.pdb
remote

View file

@ -3,6 +3,5 @@ members = [
"crates/common",
"crates/server",
"crates/client",
"crates/cli",
]
resolver = "2"

134
README.md
View file

@ -4,7 +4,7 @@
<img src="assets/logo.png" width="150" alt="helios-remote logo" />
</p>
**AI-first remote control tool** — a relay server + Windows client written in Rust. Lets an AI agent take full control of a remote Windows machine via a lightweight WebSocket relay.
**AI-first remote control tool** — a relay server + Windows client written in Rust. Lets an AI agent (or any HTTP client) take full control of a remote Windows machine via a lightweight WebSocket relay.
## Quick Connect
@ -26,91 +26,109 @@ irm https://raw.githubusercontent.com/agent-helios/helios-remote/master/scripts/
---
## How It Works
## Architecture
```
helios-remote/
├── crates/
│ ├── common/ # Shared protocol types, WebSocket message definitions
│ ├── server/ # Relay server (REST API + WebSocket hub)
│ └── client/ # Windows client — Phase 2 (stub only)
├── Cargo.toml # Workspace root
└── README.md
```
### How It Works
```
AI Agent
▼ helios-remote-cli
helios-remote-relay ──WebSocket── helios-remote-client (Windows)
│ REST API (X-Api-Key)
helios-server ──WebSocket── helios-client (Windows)
│ │
POST /sessions/:id/screenshot │ Captures screen → base64 PNG
POST /sessions/:id/exec │ Runs command in persistent shell
POST /sessions/:id/click │ Simulates mouse click
POST /sessions/:id/type │ Types text
```
1. The **Windows client** connects to the relay server via WebSocket and registers with its device label.
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.
1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` message.
2. The **AI agent** calls the REST API to issue commands.
3. The relay server forwards commands to the correct client session and streams back responses.
Device labels are the sole identifier. Only one client instance can run per device.
## Server
---
### REST API
## remote CLI
All endpoints require the `X-Api-Key` header.
```bash
remote devices # list connected devices
remote screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
remote screenshot <device> <window_label> # screenshot a specific window
remote exec <device> <command...> # run shell command (PowerShell)
remote exec <device> --timeout 600 <command...> # with custom timeout (seconds)
remote windows <device> # list visible windows
remote focus <device> <window_label> # focus a window
remote maximize <device> <window_label> # maximize and focus a window
remote minimize-all <device> # minimize all windows
remote inform <device> "Something happened" # notify user (fire-and-forget, no response)
remote inform <device> "message" --title "Title" # with custom dialog title
remote run <device> <program> [args...] # launch program (fire-and-forget)
remote clipboard-get <device> # get clipboard text
remote clipboard-set <device> <text> # set clipboard text
remote upload <device> <local> <remote> # upload file to device
remote download <device> <remote> <local> # download file from device
remote version <device> # compare latest/relay/cli/client commits
remote update <device> # update all components to latest version
remote logs <device> # fetch last 20 lines of client log (default)
remote logs <device> --lines 200 # custom line count
```
| Method | Path | Description |
|---|---|---|
| `GET` | `/sessions` | List all connected clients |
| `POST` | `/sessions/:id/screenshot` | Request a screenshot (returns base64 PNG) |
| `POST` | `/sessions/:id/exec` | Execute a shell command |
| `POST` | `/sessions/:id/click` | Simulate a mouse click |
| `POST` | `/sessions/:id/type` | Type text |
| `POST` | `/sessions/:id/label` | Rename a session |
### Update System
### WebSocket
`remote update <device>` checks `version.json` on the download server for the latest available commit and updates any component that's behind:
Clients connect to `ws://host:3000/ws`. No auth required at the transport layer — the server trusts all WS connections as client agents.
- **Relay** — downloads new binary, replaces itself, restarts via systemd
- **Client** — downloads new binary, replaces itself, relaunches automatically
- **CLI** — downloads new binary, replaces itself, re-executes the update command
CI publishes new binaries after every push to `master` but does **not** auto-restart the relay. Updates only happen when explicitly triggered via `remote update`.
---
## Server Setup
### Running the Server
```bash
HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server
```
Environment variables:
| Variable | Default | Description |
|---|---|---|
| `HELIOS_API_KEY` | `dev-secret` | API key |
| `HELIOS_API_KEY` | `dev-secret` | API key for REST endpoints |
| `HELIOS_BIND` | `0.0.0.0:3000` | Listen address |
| `RUST_LOG` | `helios_server=debug` | Log level |
---
### Example API Usage
## Downloads
```bash
# List sessions
curl -H "X-Api-Key: your-secret-key" http://localhost:3000/sessions
Pre-built binaries are available at:
# Take a screenshot
curl -s -X POST -H "X-Api-Key: your-secret-key" \
http://localhost:3000/sessions/<session-id>/screenshot
| Binary | Platform | Link |
|---|---|---|
| `helios-remote-client` | Windows | [helios-remote-client-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe) |
| `helios-remote-cli` | Linux | [helios-remote-cli-linux](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux) |
| `helios-remote-cli` | Windows | [helios-remote-cli-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-windows.exe) |
# Run a command
curl -s -X POST -H "X-Api-Key: your-secret-key" \
-H "Content-Type: application/json" \
-d '{"command": "whoami"}' \
http://localhost:3000/sessions/<session-id>/exec
The relay server (`helios-remote-relay`) runs on the VPS and is not distributed.
# Click at coordinates
curl -s -X POST -H "X-Api-Key: your-secret-key" \
-H "Content-Type: application/json" \
-d '{"x": 100, "y": 200, "button": "left"}' \
http://localhost:3000/sessions/<session-id>/click
```
---
## Client (Phase 2)
See [`crates/client/README.md`](crates/client/README.md) for the planned Windows client implementation.
## Development
```bash
# Build everything
cargo build
# Run tests
cargo test
# Run server in dev mode
RUST_LOG=debug cargo run -p helios-server
```
## License
MIT

104
SKILL.md
View file

@ -1,104 +0,0 @@
# Skill: helios-remote
> **Note:** This repo also contains Rust code (client, server) and assets.
> Those files are not relevant for using the skill — don't read or modify them.
Control PCs connected to the Helios Remote Relay Server.
## When to use
When Moritz asks to do something on a connected PC:
- "Do X on my PC..."
- "Check what's running on the computer..."
- "Take a screenshot of..."
- General: remote access to an online PC
## Setup
- **Script:** `skills/helios-remote/helios`
- **Config:** `skills/helios-remote/config.env` (URL + API key, don't modify)
- `SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote`
## Important Rules
- **Before destructive actions** (wallpaper, registry, system settings, deleting files) always read the current state first!
- Wallpaper: `(Get-ItemProperty 'HKCU:\Control Panel\Desktop').WallPaper`
- **Device labels are lowercase**, no whitespace, only `a-z 0-9 - _` (e.g. `moritz_pc`)
## Commands
```bash
SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote
# List connected devices
$SKILL_DIR/helios devices
# Screenshot → /tmp/helios-remote-screenshot.png
# ALWAYS prefer window screenshots (saves bandwidth)!
$SKILL_DIR/helios screenshot moritz-pc chrome # window by label
$SKILL_DIR/helios screenshot moritz-pc screen # full screen only when no window known
# List visible windows (use labels for screenshot/focus/maximize)
$SKILL_DIR/helios windows moritz-pc
# Window labels come from the process name (e.g. chrome, discord, pycharm64)
# Duplicates get a number suffix: chrome, chrome2, chrome3
# Use `windows` to discover labels before targeting a specific window
# Focus / maximize a window
$SKILL_DIR/helios focus moritz-pc discord
$SKILL_DIR/helios maximize moritz-pc chrome
# Minimize all windows
$SKILL_DIR/helios minimize-all moritz-pc
# Shell command (PowerShell, no wrapper needed)
$SKILL_DIR/helios exec moritz-pc "Get-Process"
$SKILL_DIR/helios exec moritz-pc "hostname"
# With longer timeout for downloads etc. (default: 30s)
$SKILL_DIR/helios exec moritz-pc --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip"
# Launch program (fire-and-forget)
$SKILL_DIR/helios run moritz-pc notepad.exe
# Ask user to do something (shows MessageBox, blocks until OK)
$SKILL_DIR/helios prompt moritz-pc "Please click Save, then OK"
$SKILL_DIR/helios prompt moritz-pc "UAC dialog coming - please confirm" --title "Action required"
# Clipboard
$SKILL_DIR/helios clipboard-get moritz-pc
$SKILL_DIR/helios clipboard-set moritz-pc "Text for clipboard"
# File transfer
$SKILL_DIR/helios upload moritz-pc /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt"
$SKILL_DIR/helios download moritz-pc "C:\Users\Moritz\file.txt" /tmp/downloaded.txt
# Version: compare latest available vs running commits (relay / cli / client)
$SKILL_DIR/remote version moritz-pc
# Update: bring all components (relay, cli, client) to latest version
# CI publishes new binaries but does NOT auto-restart — this triggers the actual update
$SKILL_DIR/remote update moritz-pc
# Client log (last 20 lines by default, --lines for more)
$SKILL_DIR/remote logs moritz-pc
$SKILL_DIR/remote logs moritz-pc --lines 200
```
## Typical Workflow: UI Task
1. `windows <device>` → find the window label
2. `screenshot <device> <window_label>` → look at it
3. `focus <device> <window_label>` → bring it to front if needed
4. `exec` → perform the action
5. `screenshot <device> <window_label>` → verify result
## ⚠️ Prompt Rule
**Never interact with UI blindly.** When you need the user to click something:
```bash
$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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 530 KiB

Before After
Before After

View file

@ -1,2 +0,0 @@
HELIOS_REMOTE_URL=https://remote.agent-helios.me
HELIOS_REMOTE_API_KEY=SqY8jLUrZugp6N4UhVPq7KDT0CeU2P7

View file

@ -1,2 +0,0 @@
HELIOS_REMOTE_URL=https://your-relay-server.example.com
HELIOS_REMOTE_API_KEY=your-api-key-here

View file

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

View file

@ -1,11 +0,0 @@
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}");
}

View file

@ -1,888 +0,0 @@
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,
},
/// Update all components (relay, client, CLI) if behind
Update {
/// 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::Update { device } => {
validate_label(&device);
// Fetch latest available commit from version.json
let latest_commit = match reqwest::blocking::get("https://agent-helios.me/downloads/helios-remote/version.json") {
Ok(r) => r
.json::<Value>()
.ok()
.and_then(|v| v["commit"].as_str().map(String::from))
.unwrap_or_else(|| "?".into()),
Err(e) => format!("error: {}", e),
};
// Fetch all three running commits
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!("error: {}", e),
};
let client_commit = {
let data = req(&cfg, "GET", &format!("/devices/{}/version", device), None, 10);
data["commit"].as_str().unwrap_or("?").to_string()
};
let cli_commit = GIT_COMMIT;
println!(" latest {}", latest_commit);
println!(" relay {}", relay_commit);
println!(" cli {}", cli_commit);
println!(" client {}", client_commit);
let all_current = relay_commit == latest_commit && cli_commit == latest_commit && client_commit == latest_commit;
if all_current {
println!(" ✅ Already up to date (commit: {})", latest_commit);
return;
}
println!();
let mut updated_any = false;
// Update relay if needed
if relay_commit != latest_commit {
println!(" → Updating relay...");
let data = req(&cfg, "POST", "/relay/update", None, 15);
println!(" {}", data["message"].as_str().unwrap_or("triggered"));
updated_any = true;
}
// Update client if needed
if client_commit != latest_commit {
println!(" → Updating client on {}...", device);
let data = req(
&cfg,
"POST",
&format!("/devices/{}/update", device),
None,
65,
);
println!(
" {}",
data["message"].as_str().unwrap_or(
if data["success"].as_bool() == Some(true) { "triggered" } else { "failed" }
)
);
updated_any = true;
}
// Self-update CLI if needed
if cli_commit != latest_commit {
println!(" → Updating CLI...");
#[cfg(target_os = "windows")]
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-cli-windows.exe";
#[cfg(all(not(target_os = "windows"), target_arch = "aarch64"))]
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux-aarch64";
#[cfg(all(not(target_os = "windows"), not(target_arch = "aarch64")))]
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux";
let bytes = match reqwest::blocking::get(url) {
Ok(r) => match r.bytes() {
Ok(b) => b,
Err(e) => {
eprintln!("[helios-remote] CLI update: read failed: {}", e);
process::exit(1);
}
},
Err(e) => {
eprintln!("[helios-remote] CLI update: download failed: {}", e);
process::exit(1);
}
};
let exe = std::env::current_exe().unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: current_exe failed: {}", e);
process::exit(1);
});
#[cfg(not(target_os = "windows"))]
{
let tmp = exe.with_extension("new");
std::fs::write(&tmp, &bytes).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: write failed: {}", e);
process::exit(1);
});
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
std::fs::rename(&tmp, &exe).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: rename failed: {}", e);
process::exit(1);
});
println!(" CLI updated. Re-executing...");
use std::os::unix::process::CommandExt;
let args: Vec<String> = std::env::args().collect();
let err = std::process::Command::new(&exe).args(&args[1..]).exec();
eprintln!("[helios-remote] CLI re-exec failed: {}", err);
process::exit(1);
}
#[cfg(target_os = "windows")]
{
let tmp = exe.with_extension("update.exe");
std::fs::write(&tmp, &bytes).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: write failed: {}", e);
process::exit(1);
});
let old = exe.with_extension("old.exe");
let _ = std::fs::remove_file(&old);
let _ = std::fs::rename(&exe, &old);
std::fs::rename(&tmp, &exe).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: rename failed: {}", e);
process::exit(1);
});
println!(" CLI updated. Please restart the command.");
}
updated_any = true;
}
if updated_any {
println!();
println!(" ✅ Update(s) triggered.");
}
}
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(""));
}
}
}

View file

@ -1,10 +1,10 @@
[package]
name = "helios-remote-client"
name = "helios-client"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "helios-remote-client"
name = "helios-client"
path = "src/main.rs"
[dependencies]
@ -13,24 +13,13 @@ tokio-tungstenite = { version = "0.21", features = ["connect", "native-tls"] }
native-tls = { version = "0.2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
chrono = "0.4"
helios-common = { path = "../common" }
uuid = { version = "1", features = ["v4"] }
dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22"
png = "0.17"
futures-util = "0.3"
colored = "2"
scopeguard = "1"
reqwest = { version = "0.12", features = ["json"] }
terminal_size = "0.3"
unicode-width = "0.1"
[build-dependencies]
winres = "0.1"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.54", features = [
@ -39,8 +28,4 @@ windows = { version = "0.54", features = [
"Win32_UI_Input_KeyboardAndMouse",
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
"Win32_System_Console",
"Win32_System_ProcessStatus",
"Win32_Graphics_Dwm",
] }

View file

@ -1,40 +1,36 @@
# helios-client
# helios-client (Phase 2 — not yet implemented)
Windows client for helios-remote. Connects to the relay server via WebSocket and executes commands.
This crate will contain the Windows remote-control client for `helios-remote`.
## Features
## Planned Features
- 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)
- 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
## Configuration
## Planned Tech Stack
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 - _`
| 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 |
Config is saved to `%APPDATA%/helios-remote/config.toml`.
## Build Target
## 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,46 +0,0 @@
fn main() {
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
let hash = hash.trim();
println!("cargo:rustc-env=GIT_COMMIT={}", if hash.is_empty() { "unknown" } else { hash });
println!("cargo:rerun-if-changed=.git/HEAD");
// Embed Windows icon when cross-compiling for Windows
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
// Find windres: prefer arch-prefixed, fall back to plain windres
let windres = if std::process::Command::new("x86_64-w64-mingw32-windres")
.arg("--version")
.output()
.is_ok()
{
"x86_64-w64-mingw32-windres"
} else {
"windres"
};
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out_dir = std::env::var("OUT_DIR").unwrap();
let mut res = winres::WindowsResource::new();
res.set_icon(&format!("{}/../../assets/icon.ico", manifest_dir));
res.set_toolkit_path("/usr");
res.set_windres_path(windres);
res.set_ar_path("x86_64-w64-mingw32-ar");
match res.compile() {
Ok(_) => {
println!("cargo:warning=Icon embedded successfully via {windres}");
// Pass resource.o directly as linker arg (avoids ld skipping unreferenced archive members)
println!("cargo:rustc-link-arg={}/resource.o", out_dir);
}
Err(e) => {
println!("cargo:warning=winres failed: {e}");
println!("cargo:warning=windres path used: {windres}");
}
}
}
}

View file

@ -1,149 +0,0 @@
/// Terminal display helpers — table-style command rows with live spinner.
///
/// Layout (no borders, aligned columns):
/// {2 spaces}{action_emoji (2 display cols)}{2 spaces}{name:<NAME_W}{2 spaces}{payload:<payload_w}{2 spaces}{status_emoji}{2 spaces}{result}
use std::io::Write;
use colored::Colorize;
use unicode_width::UnicodeWidthStr;
/// Pad an emoji/symbol string to exactly 2 terminal display columns.
/// Some symbols (, ☀, ⚠, # …) render as 1-wide; we add a space so columns align.
fn emoji_cell(s: &str) -> String {
let w = UnicodeWidthStr::width(s);
if w < 2 {
format!("{s} ")
} else {
s.to_string()
}
}
// Fixed column widths (in terminal display columns, ASCII-only content assumed for name/payload/result)
const NAME_W: usize = 14;
const MIN_PAYLOAD_W: usize = 10;
const MIN_RESULT_W: usize = 10;
// Overhead: 2 (indent) + 2 (action emoji) + 2 (gap) + NAME_W + 2 (gap) + 2 (gap) + 2 (status emoji) + 2 (gap)
const FIXED_OVERHEAD: usize = 2 + 2 + 2 + NAME_W + 2 + 2 + 2 + 2;
pub fn terminal_width() -> usize {
terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(120)
.max(60)
}
/// Split remaining space between payload and result columns.
/// payload gets ~55%, result gets the rest; both have minimums.
fn col_widths() -> (usize, usize) {
let tw = terminal_width();
let remaining = tw.saturating_sub(FIXED_OVERHEAD).max(MIN_PAYLOAD_W + MIN_RESULT_W);
let payload_w = (remaining * 55 / 100).max(MIN_PAYLOAD_W);
let result_w = remaining.saturating_sub(payload_w).max(MIN_RESULT_W);
(payload_w, result_w)
}
/// Truncate a string to at most `max` Unicode chars, appending `…` if cut.
/// Must be called on PLAIN (uncolored) text — ANSI codes confuse char counting.
pub fn trunc(s: &str, max: usize) -> String {
if max == 0 {
return String::new();
}
let mut chars = s.chars();
let truncated: String = chars.by_ref().take(max).collect();
if chars.next().is_some() {
let mut t: String = truncated.chars().take(max.saturating_sub(1)).collect();
t.push('…');
t
} else {
truncated
}
}
/// Format one table row into a String (no trailing newline).
/// Truncation happens on plain `result` BEFORE colorizing, so ANSI reset codes are never cut off.
fn format_row(action: &str, name: &str, payload: &str, status: &str, result: &str, err: bool) -> String {
let (payload_w, result_w) = col_widths();
let p = trunc(payload, payload_w);
// Truncate BEFORE colorizing — avoids dangling ANSI escape sequences
let r_plain = trunc(result, result_w);
let r = if err { r_plain.red().to_string() } else { r_plain };
format!(
" {} {:<name_w$} {:<payload_w$} {} {}",
emoji_cell(action),
name,
p,
emoji_cell(status),
r,
name_w = NAME_W,
payload_w = payload_w,
)
}
/// Print the "running" row (🔄 spinner).
pub fn cmd_start(action: &str, name: &str, payload: &str) {
let line = format_row(action, name, payload, "🔄", "", false);
println!("{}", line);
let _ = std::io::stdout().flush();
}
/// Overwrite the previous line (cursor up + clear) with the completed row.
pub fn cmd_done(action: &str, name: &str, payload: &str, success: bool, result: &str) {
let status = if success { "" } else { "" };
let line = format_row(action, name, payload, status, result, !success);
// \x1b[1A = cursor up 1, \r = go to col 0, \x1b[2K = clear line
print!("\x1b[1A\r\x1b[2K{}\n", line);
let _ = std::io::stdout().flush();
crate::logger::write_line(if success { "OK" } else { "ERROR" }, &format!("{name} {payload}{result}"));
}
/// Info line for the startup header — uses same column alignment as table rows.
pub fn info_line(emoji: &str, key: &str, value: &str) {
// Match table layout: 2 spaces + 2-wide emoji + 2 spaces + name (NAME_W) + 2 spaces + value
println!(" {} {:<name_w$} {}", emoji_cell(emoji), key, value, name_w = NAME_W);
}
/// Print the prompt "awaiting input" row + the 🎤 answer input prefix.
/// The caller should read stdin immediately after this returns.
pub fn prompt_waiting(message: &str) {
let (payload_w, _result_w) = col_widths();
let p = trunc(message, payload_w);
// 🌀 row: show message + 🔄 + "awaiting input"
println!(
" {} {:<name_w$} {:<payload_w$} {} awaiting input",
emoji_cell("🌀"), "prompt", p, emoji_cell("🔄"),
name_w = NAME_W, payload_w = payload_w,
);
// 🎤 answer input prefix — no newline, user types here
print!(" {} {:<name_w$} ", emoji_cell("🎤"), "answer", name_w = NAME_W);
let _ = std::io::stdout().flush();
}
/// Overwrite the 🌀 and 🎤 lines with the final state after input was received.
/// Must be called after the user pressed Enter (cursor is on the line after 🎤).
pub fn prompt_done(message: &str, answer: &str) {
let (payload_w, result_w) = col_widths();
let p = trunc(message, payload_w);
// Go up 2 lines (🎤 line + 🌀 line), rewrite both
print!("\x1b[2A\r\x1b[2K");
// Rewrite 🌀 row as done
println!(
" {} {:<name_w$} {:<payload_w$} {} done",
emoji_cell("🌀"), "prompt", p, emoji_cell(""),
name_w = NAME_W, payload_w = payload_w,
);
// Clear 🎤 line + rewrite with purple answer
print!("\r\x1b[2K");
let a = trunc(answer, payload_w + result_w + 4); // answer spans both columns
println!(
" {} {:<name_w$} {}",
emoji_cell("🎤"), "answer", a.purple(),
name_w = NAME_W,
);
let _ = std::io::stdout().flush();
crate::logger::write_line("OK", &format!("prompt → {answer}"));
}
pub fn err(emoji: &str, msg: &str) {
println!(" {} {}", emoji_cell(emoji), msg.red());
crate::logger::write_line("ERROR", msg);
}

154
crates/client/src/input.rs Normal file
View file

@ -0,0 +1,154 @@
/// 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

@ -1,59 +0,0 @@
/// File logger — writes structured log lines alongside the pretty terminal output.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::{Mutex, OnceLock};
static LOG_FILE: OnceLock<Mutex<File>> = OnceLock::new();
static LOG_PATH: OnceLock<String> = OnceLock::new();
pub fn init() {
let path = log_path();
// Create parent dir if needed
if let Some(parent) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(parent);
}
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => {
LOG_PATH.set(path.clone()).ok();
LOG_FILE.set(Mutex::new(f)).ok();
write_line("INFO", "helios-remote started");
}
Err(e) => eprintln!("[logger] Failed to open log file {path}: {e}"),
}
}
fn log_path() -> String {
#[cfg(windows)]
{
let base = std::env::var("LOCALAPPDATA")
.unwrap_or_else(|_| "C:\\Temp".to_string());
format!("{base}\\helios-remote\\helios-remote.log")
}
#[cfg(not(windows))]
{
"/tmp/helios-remote.log".to_string()
}
}
pub fn get_log_path() -> String {
LOG_PATH.get().cloned().unwrap_or_else(log_path)
}
pub fn write_line(level: &str, msg: &str) {
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("{now} [{level:<5}] {msg}\n");
if let Some(mutex) = LOG_FILE.get() {
if let Ok(mut f) = mutex.lock() {
let _ = f.write_all(line.as_bytes());
}
}
}
/// Read the last `n` lines from the log file.
pub fn tail(n: u32) -> String {
let path = get_log_path();
let content = std::fs::read_to_string(&path).unwrap_or_default();
let lines: Vec<&str> = content.lines().collect();
let start = lines.len().saturating_sub(n as usize);
lines[start..].join("\n")
}

View file

@ -2,136 +2,25 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use colored::Colorize;
use futures_util::{SinkExt, StreamExt};
use native_tls::TlsConnector;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector};
use tracing::{error, info, warn};
use base64::Engine;
use helios_common::{ClientMessage, ServerMessage};
#[allow(unused_imports)]
use reqwest;
use helios_common::protocol::{is_valid_label, sanitize_label};
mod display;
mod logger;
mod shell;
mod screenshot;
mod input;
mod windows_mgmt;
use display::trunc;
fn banner() {
println!();
println!(" {} {}", "".yellow().bold(), "HELIOS REMOTE".bold());
display::info_line("🔗", "commit:", &env!("GIT_COMMIT").dimmed().to_string());
}
fn print_device_info(label: &str) {
#[cfg(windows)]
{
let admin = is_admin();
let priv_str = if admin {
"admin".dimmed().to_string()
} else {
"no admin".dimmed().to_string()
};
display::info_line("👤", "privileges:", &priv_str);
}
#[cfg(not(windows))]
display::info_line("👤", "privileges:", &"no admin".dimmed().to_string());
display::info_line("🖥", "device:", &label.dimmed().to_string());
println!();
}
#[cfg(windows)]
fn is_admin() -> bool {
use windows::Win32::UI::Shell::IsUserAnAdmin;
unsafe { IsUserAnAdmin().as_bool() }
}
#[cfg(windows)]
fn enable_ansi() {
use windows::Win32::System::Console::{
GetConsoleMode, GetStdHandle, SetConsoleMode,
ENABLE_VIRTUAL_TERMINAL_PROCESSING, STD_OUTPUT_HANDLE,
};
unsafe {
if let Ok(handle) = GetStdHandle(STD_OUTPUT_HANDLE) {
let mut mode = Default::default();
if GetConsoleMode(handle, &mut mode).is_ok() {
let _ = SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
}
}
}
}
// ── 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)]
#[derive(Debug, Serialize, Deserialize)]
struct Config {
relay_url: String,
api_key: String,
label: String,
relay_code: String,
label: Option<String>,
}
impl Config {
@ -139,148 +28,82 @@ impl Config {
let base = dirs::config_dir()
.or_else(|| dirs::home_dir())
.unwrap_or_else(|| PathBuf::from("."));
base.join("helios-remote").join("config.toml")
base.join("helios-remote").join("config.json")
}
fn load() -> Option<Self> {
let path = Self::config_path();
let data = std::fs::read_to_string(&path).ok()?;
toml::from_str(&data).ok()
serde_json::from_str(&data).ok()
}
fn save(&self) -> std::io::Result<()> {
let path = Self::config_path();
std::fs::create_dir_all(path.parent().unwrap())?;
let data = toml::to_string_pretty(self).unwrap();
let data = serde_json::to_string_pretty(self).unwrap();
std::fs::write(&path, data)?;
Ok(())
}
}
fn prompt_config() -> Config {
use std::io::Write;
let relay_url = {
let default = "wss://remote.agent-helios.me/ws";
print!(" {} Relay URL [{}]: ", "".cyan().bold(), default);
std::io::stdout().flush().unwrap();
println!("Relay server URL [default: wss://remote.agent-helios.me/ws]: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim();
if trimmed.is_empty() {
default.to_string()
"wss://remote.agent-helios.me/ws".to_string()
} else {
trimmed.to_string()
}
};
let api_key = {
print!(" {} API Key: ", "".cyan().bold());
std::io::stdout().flush().unwrap();
let relay_code = {
println!("Enter relay code: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
};
let label = {
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());
}
println!("Label for this machine (optional, press Enter to skip): ");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
};
Config { relay_url, api_key, label }
Config { relay_url, relay_code, label }
}
#[tokio::main]
async fn main() {
#[cfg(windows)]
enable_ansi();
logger::init();
if std::env::var("RUST_LOG").is_err() {
unsafe { std::env::set_var("RUST_LOG", "off"); }
}
banner();
// Clean up leftover .old.exe from previous self-update (Windows can't delete running exe)
#[cfg(target_os = "windows")]
if let Ok(exe) = std::env::current_exe() {
let old = exe.with_extension("old.exe");
let _ = std::fs::remove_file(&old);
}
// 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());
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "helios_client=info".to_string()),
)
.init();
// Load or prompt for config
let config = match Config::load() {
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
}
info!("Loaded config from {:?}", Config::config_path());
c
}
None => {
display::info_line("", "setup:", "No config found — first-time setup");
println!();
info!("No config found — prompting for setup");
let c = prompt_config();
println!();
if let Err(e) = c.save() {
display::err("", &format!("Failed to save config: {e}"));
error!("Failed to save config: {e}");
} else {
display::info_line("", "config:", "saved");
info!("Config saved to {:?}", Config::config_path());
}
// Self-restart after first-time setup so all config takes effect cleanly
println!();
display::info_line("🔄", "restart:", "Config saved. Restarting...");
release_instance_lock();
let exe = std::env::current_exe().expect("Failed to get current exe path");
let args: Vec<String> = std::env::args().skip(1).collect();
let _ = std::process::Command::new(exe).args(&args).spawn();
std::process::exit(0);
c
}
};
let label = config.label.clone();
print_device_info(&label);
let config = Arc::new(config);
let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
@ -289,49 +112,43 @@ async fn main() {
const MAX_BACKOFF: Duration = Duration::from_secs(30);
loop {
let host = config.relay_url
.trim_start_matches("wss://")
.trim_start_matches("ws://")
.split('/')
.next()
.unwrap_or(&config.relay_url);
display::cmd_start("🌐", "connect", host);
info!("Connecting to {}", config.relay_url);
// Build TLS connector - accepts self-signed certs for internal CA (Caddy tls internal)
let tls_connector = TlsConnector::builder()
.danger_accept_invalid_certs(true)
.build()
.expect("TLS connector build failed");
let connector = Connector::NativeTls(tls_connector);
match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await {
Ok((ws_stream, _)) => {
display::cmd_done("🌐", "connect", host, true, "connected");
backoff = Duration::from_secs(1);
info!("Connected!");
backoff = Duration::from_secs(1); // reset on success
let (mut write, mut read) = ws_stream.split();
// Send Hello with device label
// Send Hello
let hello = ClientMessage::Hello {
label: label.clone(),
label: config.label.clone(),
};
let hello_json = serde_json::to_string(&hello).unwrap();
if let Err(e) = write.send(Message::Text(hello_json)).await {
display::err("", &format!("hello failed: {e}"));
error!("Failed to send Hello: {e}");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
continue;
}
// Shared write half
let write = Arc::new(Mutex::new(write));
// Process messages
while let Some(msg_result) = read.next().await {
match msg_result {
Ok(Message::Text(text)) => {
let server_msg: ServerMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
display::err("", &format!("Failed to parse server message: {e}"));
warn!("Failed to parse server message: {e}\nRaw: {text}");
continue;
}
};
@ -341,16 +158,10 @@ async fn main() {
tokio::spawn(async move {
let response = handle_message(server_msg, shell_clone).await;
let json = match serde_json::to_string(&response) {
Ok(j) => j,
Err(e) => {
display::err("", &format!("Failed to serialize response: {e}"));
return;
}
};
let json = serde_json::to_string(&response).unwrap();
let mut w = write_clone.lock().await;
if let Err(e) = w.send(Message::Text(json)).await {
display::err("", &format!("Failed to send response: {e}"));
error!("Failed to send response: {e}");
}
});
}
@ -359,21 +170,21 @@ async fn main() {
let _ = w.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
display::cmd_start("🌐", "connect", host);
display::cmd_done("🌐", "connect", host, false, "connection lost");
info!("Server closed connection");
break;
}
Err(e) => {
display::cmd_done("🌐", "connect", host, false, &format!("lost: {e}"));
error!("WebSocket error: {e}");
break;
}
_ => {}
}
}
warn!("Disconnected. Reconnecting in {:?}...", backoff);
}
Err(e) => {
display::cmd_start("🌐", "connect", host);
display::cmd_done("🌐", "connect", host, false, &format!("{e}"));
error!("Connection failed: {e}");
}
}
@ -382,377 +193,138 @@ async fn main() {
}
}
fn hostname() -> String {
std::fs::read_to_string("/etc/hostname")
.unwrap_or_default()
.trim()
.to_string()
.or_else(|| std::env::var("COMPUTERNAME").ok())
.unwrap_or_else(|| "unknown".to_string())
}
trait OrElseString {
fn or_else(self, f: impl FnOnce() -> Option<String>) -> String;
fn unwrap_or_else(self, f: impl FnOnce() -> String) -> String;
}
impl OrElseString for String {
fn or_else(self, f: impl FnOnce() -> Option<String>) -> String {
if self.is_empty() { f().unwrap_or_default() } else { self }
}
fn unwrap_or_else(self, f: impl FnOnce() -> String) -> String {
if self.is_empty() { f() } else { self }
}
}
async fn handle_message(
msg: ServerMessage,
shell: Arc<Mutex<shell::PersistentShell>>,
) -> ClientMessage {
match msg {
ServerMessage::WindowScreenshotRequest { request_id, window_id } => {
let payload = format!("window {window_id}");
display::cmd_start("📷", "screenshot", &payload);
match screenshot::take_window_screenshot(window_id) {
Ok((image_base64, width, height)) => {
display::cmd_done("📷", "screenshot", &payload, true, &format!("{width}×{height}"));
ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
}
Err(e) => {
display::cmd_done("📷", "screenshot", &payload, false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Window screenshot failed: {e}") }
}
}
}
ServerMessage::ScreenshotRequest { request_id } => {
display::cmd_start("📷", "screenshot", "screen");
match screenshot::take_screenshot() {
Ok((image_base64, width, height)) => {
display::cmd_done("📷", "screenshot", "screen", true, &format!("{width}×{height}"));
ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
}
Ok((image_base64, width, height)) => ClientMessage::ScreenshotResponse {
request_id,
image_base64,
width,
height,
},
Err(e) => {
display::cmd_done("📷", "screenshot", "screen", false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") }
error!("Screenshot failed: {e}");
ClientMessage::Error {
request_id,
message: format!("Screenshot failed: {e}"),
}
}
}
}
ServerMessage::InformRequest { request_id, message, title } => {
let msg = message.clone();
let ttl = title.clone().unwrap_or_else(|| "Helios".to_string());
// Fire-and-forget: show MessageBox in background thread, don't block
std::thread::spawn(move || {
#[cfg(windows)]
unsafe {
use windows::core::PCWSTR;
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, HWND_DESKTOP};
let msg_w: Vec<u16> = msg.encode_utf16().chain(std::iter::once(0)).collect();
let ttl_w: Vec<u16> = ttl.encode_utf16().chain(std::iter::once(0)).collect();
MessageBoxW(HWND_DESKTOP, PCWSTR(msg_w.as_ptr()), PCWSTR(ttl_w.as_ptr()), MB_OK | MB_ICONINFORMATION);
}
#[cfg(not(windows))]
let _ = (msg, ttl);
});
display::cmd_done("📢", "inform", &message, true, "sent");
ClientMessage::Ack { request_id }
}
ServerMessage::PromptRequest { request_id, message, title: _ } => {
display::prompt_waiting(&message);
let answer = tokio::task::spawn_blocking(|| {
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
input.trim().to_string()
}).await.unwrap_or_default();
display::prompt_done(&message, &answer);
ClientMessage::PromptResponse { request_id, answer }
}
ServerMessage::ExecRequest { request_id, command, timeout_ms } => {
let payload = trunc(&command, 80);
display::cmd_start("", "execute", &payload);
ServerMessage::ExecRequest { request_id, command } => {
info!("Exec: {command}");
let mut sh = shell.lock().await;
match sh.run(&command, timeout_ms).await {
Ok((stdout, stderr, exit_code)) => {
let result = if exit_code != 0 {
let err_line = stderr.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty()
&& !l.starts_with("In Zeile:")
&& !l.starts_with("+ ")
&& !l.starts_with("CategoryInfo")
&& !l.starts_with("FullyQualifiedErrorId"))
.unwrap_or("error")
.to_string();
err_line
} else {
stdout.trim().lines().next().unwrap_or("").to_string()
};
display::cmd_done("", "execute", &payload, exit_code == 0, &result);
ClientMessage::ExecResponse { request_id, stdout, stderr, exit_code }
}
match sh.run(&command).await {
Ok((stdout, stderr, exit_code)) => ClientMessage::ExecResponse {
request_id,
stdout,
stderr,
exit_code,
},
Err(e) => {
display::cmd_done("", "execute", &payload, false, &format!("exec failed: {e}"));
ClientMessage::Error { request_id, message: format!("Exec failed for command {:?}.\nError: {e}", command) }
error!("Exec failed for command {:?}: {e}", command);
ClientMessage::Error {
request_id,
message: format!(
"Exec failed for command {:?}.\nError: {e}\nContext: persistent shell may have died.",
command
),
}
}
}
}
ServerMessage::ClickRequest { request_id, x, y, button } => {
info!("Click: ({x},{y}) {:?}", button);
match input::click(x, y, &button) {
Ok(()) => ClientMessage::Ack { request_id },
Err(e) => {
error!("Click failed at ({x},{y}): {e}");
ClientMessage::Error {
request_id,
message: format!("Click at ({x},{y}) failed: {e}"),
}
}
}
}
ServerMessage::TypeRequest { request_id, text } => {
info!("Type: {} chars", text.len());
match input::type_text(&text) {
Ok(()) => ClientMessage::Ack { request_id },
Err(e) => {
error!("Type failed: {e}");
ClientMessage::Error {
request_id,
message: format!("Type failed: {e}"),
}
}
}
}
ServerMessage::ListWindowsRequest { request_id } => {
display::cmd_start("🪟", "list windows", "");
info!("ListWindows");
match windows_mgmt::list_windows() {
Ok(windows) => {
display::cmd_done("🪟", "list windows", "", true, &format!("{} windows", windows.len()));
ClientMessage::ListWindowsResponse { request_id, windows }
}
Ok(windows) => ClientMessage::ListWindowsResponse { request_id, windows },
Err(e) => {
display::cmd_done("🪟", "list windows", "", false, &e);
error!("ListWindows failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::MinimizeAllRequest { request_id } => {
display::cmd_start("🪟", "minimize all", "");
info!("MinimizeAll");
match windows_mgmt::minimize_all() {
Ok(()) => {
display::cmd_done("🪟", "minimize all", "", true, "done");
ClientMessage::Ack { request_id }
}
Ok(()) => ClientMessage::Ack { request_id },
Err(e) => {
display::cmd_done("🪟", "minimize all", "", false, &e);
error!("MinimizeAll failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::FocusWindowRequest { request_id, window_id } => {
let payload = format!("{window_id}");
display::cmd_start("🪟", "focus window", &payload);
info!("FocusWindow: {window_id}");
match windows_mgmt::focus_window(window_id) {
Ok(()) => {
display::cmd_done("🪟", "focus window", &payload, true, "done");
ClientMessage::Ack { request_id }
}
Ok(()) => ClientMessage::Ack { request_id },
Err(e) => {
display::cmd_done("🪟", "focus window", &payload, false, &e);
error!("FocusWindow failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => {
let payload = format!("{window_id}");
display::cmd_start("🪟", "maximize", &payload);
info!("MaximizeAndFocus: {window_id}");
match windows_mgmt::maximize_and_focus(window_id) {
Ok(()) => {
display::cmd_done("🪟", "maximize", &payload, true, "done");
ClientMessage::Ack { request_id }
}
Ok(()) => ClientMessage::Ack { request_id },
Err(e) => {
display::cmd_done("🪟", "maximize", &payload, false, &e);
error!("MaximizeAndFocus failed: {e}");
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::VersionRequest { request_id } => {
display::cmd_start("", "version", "");
let version = env!("CARGO_PKG_VERSION").to_string();
let commit = env!("GIT_COMMIT").to_string();
display::cmd_done("", "version", "", true, &commit);
ClientMessage::VersionResponse { request_id, version, commit }
}
ServerMessage::LogsRequest { request_id, lines } => {
let payload = format!("last {lines} lines");
display::cmd_start("📜", "logs", &payload);
let content = logger::tail(lines);
let log_path = logger::get_log_path();
display::cmd_done("📜", "logs", &payload, true, &log_path);
ClientMessage::LogsResponse { request_id, content, log_path }
}
ServerMessage::UploadRequest { request_id, path, content_base64 } => {
let payload = trunc(&path, 60);
display::cmd_start("📁", "upload", &payload);
match (|| -> Result<(), String> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(&content_base64)
.map_err(|e| format!("base64 decode: {e}"))?;
if let Some(parent) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
Ok(())
})() {
Ok(()) => {
display::cmd_done("📁", "upload", &payload, true, "saved");
ClientMessage::Ack { request_id }
}
Err(e) => {
display::cmd_done("📁", "upload", &payload, false, &e);
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::DownloadRequest { request_id, path } => {
let payload = trunc(&path, 60);
display::cmd_start("📁", "download", &payload);
match std::fs::read(&path) {
Ok(bytes) => {
let size = bytes.len() as u64;
let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
display::cmd_done("📁", "download", &payload, true, &format!("{size} bytes"));
ClientMessage::DownloadResponse { request_id, content_base64, size }
}
Err(e) => {
display::cmd_done("📁", "download", &payload, false, &format!("read failed: {e}"));
ClientMessage::Error { request_id, message: format!("Read failed: {e}") }
}
}
}
ServerMessage::RunRequest { request_id, program, args } => {
let payload = if args.is_empty() { program.clone() } else { format!("{program} {}", args.join(" ")) };
let payload = trunc(&payload, 60);
display::cmd_start("🚀", "run", &payload);
use std::process::Command as StdCommand;
match StdCommand::new(&program).args(&args).spawn() {
Ok(_) => {
display::cmd_done("🚀", "run", &payload, true, "started");
ClientMessage::Ack { request_id }
}
Err(e) => {
display::cmd_done("🚀", "run", &payload, false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Failed to start '{}': {e}", program) }
}
}
}
ServerMessage::ClipboardGetRequest { request_id } => {
display::cmd_start("📋", "get clipboard", "");
let out = tokio::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard"])
.output().await;
match out {
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
display::cmd_done("📋", "get clipboard", "", true, &text);
ClientMessage::ClipboardGetResponse { request_id, text }
}
Err(e) => {
display::cmd_done("📋", "get clipboard", "", false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Clipboard get failed: {e}") }
}
}
}
ServerMessage::ClipboardSetRequest { request_id, text } => {
let payload = trunc(&text, 60);
display::cmd_start("📋", "set clipboard", &payload);
let cmd = format!("Set-Clipboard -Value '{}'", text.replace('\'', "''"));
let out = tokio::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &cmd])
.output().await;
match out {
Ok(_) => {
display::cmd_done("📋", "set clipboard", &payload, true, &payload);
ClientMessage::Ack { request_id }
}
Err(e) => {
display::cmd_done("📋", "set clipboard", &payload, false, &format!("{e}"));
ClientMessage::Error { request_id, message: format!("Clipboard set failed: {e}") }
}
}
}
ServerMessage::UpdateRequest { request_id } => {
display::cmd_start("🔄", "update", "downloading...");
let exe = std::env::current_exe().ok();
tokio::spawn(async move {
// Give the response time to be sent before we restart
tokio::time::sleep(tokio::time::Duration::from_millis(800)).await;
let exe = match exe {
Some(p) => p,
None => {
display::err("", "update: could not determine current exe path");
return;
}
};
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe";
let bytes = match reqwest::get(url).await {
Ok(r) => match r.bytes().await {
Ok(b) => b,
Err(e) => {
display::err("", &format!("update: read body failed: {e}"));
return;
}
},
Err(e) => {
display::err("", &format!("update: download failed: {e}"));
return;
}
};
// Write new binary to a temp path, then swap
let tmp = exe.with_extension("update.exe");
if let Err(e) = std::fs::write(&tmp, &bytes) {
display::err("", &format!("update: write failed: {e}"));
return;
}
// Rename current → .old, then tmp → current
let old = exe.with_extension("old.exe");
let _ = std::fs::remove_file(&old);
if let Err(e) = std::fs::rename(&exe, &old) {
display::err("", &format!("update: rename old failed: {e}"));
return;
}
if let Err(e) = std::fs::rename(&tmp, &exe) {
// Attempt rollback
let _ = std::fs::rename(&old, &exe);
display::err("", &format!("update: rename new failed: {e}"));
return;
}
display::cmd_done("🔄", "update", "", true, "updated — restarting");
// Delete old binary
let _ = std::fs::remove_file(&old);
// Release single-instance lock so new process can start
release_instance_lock();
// Restart with same args (new console window on Windows)
let args: Vec<String> = std::env::args().skip(1).collect();
#[cfg(target_os = "windows")]
{
// Use "start" to open a new visible console window
let exe_str = exe.to_string_lossy();
let _ = std::process::Command::new("cmd")
.args(["/c", "start", "", &exe_str])
.spawn();
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new(&exe).args(&args).spawn();
std::process::exit(0);
});
display::cmd_done("🔄", "update", "", true, "triggered");
ClientMessage::UpdateResponse {
request_id,
success: true,
message: "update triggered, client restarting...".into(),
}
}
ServerMessage::Ack { request_id } => {
info!("Server ack for {request_id}");
// Nothing to do - server acked something we sent
ClientMessage::Ack { request_id }
}
ServerMessage::Error { request_id, message } => {
display::err("", &format!("server error: {message}"));
error!("Server error (req={request_id:?}): {message}");
// No meaningful response needed but we need to return something
// Use a dummy ack if we have a request_id
if let Some(rid) = request_id {
ClientMessage::Ack { request_id: rid }
} else {
ClientMessage::Hello { label: String::new() }
ClientMessage::Hello { label: None }
}
}
}

View file

@ -3,10 +3,10 @@ use base64::Engine;
#[cfg(windows)]
pub fn take_screenshot() -> Result<(String, u32, u32), String> {
use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
GetDIBits, SelectObject, BITMAPINFO, BITMAPINFOHEADER,
GetDIBits, GetObjectW, SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
DIB_RGB_COLORS, SRCCOPY,
};
use windows::Win32::UI::WindowsAndMessaging::GetDesktopWindow;
@ -117,60 +117,6 @@ pub fn take_screenshot() -> Result<(String, u32, u32), String> {
}
}
/// Capture a specific window by cropping the full screen to its rect.
#[cfg(windows)]
pub fn take_window_screenshot(window_id: u64) -> Result<(String, u32, u32), String> {
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::GetWindowRect;
let hwnd = HWND(window_id as isize);
let (x, y, w, h) = unsafe {
let mut rect = RECT::default();
GetWindowRect(hwnd, &mut rect).map_err(|e| format!("GetWindowRect failed: {e}"))?;
let w = (rect.right - rect.left) as u32;
let h = (rect.bottom - rect.top) as u32;
if w == 0 || h == 0 { return Err(format!("Window has zero size: {w}x{h}")); }
(rect.left, rect.top, w, h)
};
// Take full screenshot and crop to window rect
let (full_b64, full_w, full_h) = take_screenshot()?;
let full_bytes = base64::engine::general_purpose::STANDARD
.decode(&full_b64).map_err(|e| format!("base64 decode: {e}"))?;
// Decode PNG back to raw RGBA
let cursor = std::io::Cursor::new(&full_bytes);
let decoder = png::Decoder::new(cursor);
let mut reader = decoder.read_info().map_err(|e| format!("PNG decode: {e}"))?;
let mut img_buf = vec![0u8; reader.output_buffer_size()];
reader.next_frame(&mut img_buf).map_err(|e| format!("PNG frame: {e}"))?;
// Clamp window rect to screen bounds
let x0 = (x.max(0) as u32).min(full_w);
let y0 = (y.max(0) as u32).min(full_h);
let x1 = ((x as u32 + w)).min(full_w);
let y1 = ((y as u32 + h)).min(full_h);
let cw = x1 - x0;
let ch = y1 - y0;
// Crop: 4 bytes per pixel (RGBA)
let mut cropped = Vec::with_capacity((cw * ch * 4) as usize);
for row in y0..y1 {
let start = ((row * full_w + x0) * 4) as usize;
let end = start + (cw * 4) as usize;
cropped.extend_from_slice(&img_buf[start..end]);
}
let png_bytes = encode_png(&cropped, cw, ch)?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
Ok((b64, cw, ch))
}
#[cfg(not(windows))]
pub fn take_window_screenshot(_window_id: u64) -> Result<(String, u32, u32), String> {
Err("Window screenshot only supported on Windows".to_string())
}
#[cfg(not(windows))]
pub fn take_screenshot() -> Result<(String, u32, u32), String> {
// Stub for non-Windows builds

View file

@ -1,51 +1,161 @@
/// Shell execution — each command runs in its own fresh process.
/// On Windows we use powershell.exe -NoProfile so the user's $PROFILE
/// (which might run `clear`) is never loaded.
use std::time::Duration;
use tokio::process::Command;
/// Persistent shell session that keeps a cmd.exe (Windows) or sh (Unix) alive
/// between commands, so state like `cd` is preserved.
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, ChildStderr};
use tracing::{debug, warn};
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
const OUTPUT_TIMEOUT_MS: u64 = 10_000;
/// Unique sentinel appended after every command to know when output is done.
const SENTINEL: &str = "__HELIOS_DONE__";
pub struct PersistentShell;
pub struct PersistentShell {
child: Option<ShellProcess>,
}
struct ShellProcess {
_child: Child,
stdin: ChildStdin,
stdout_lines: tokio::sync::Mutex<BufReader<ChildStdout>>,
stderr_lines: tokio::sync::Mutex<BufReader<ChildStderr>>,
}
impl PersistentShell {
pub fn new() -> Self { Self }
pub fn new() -> Self {
Self { child: None }
}
pub async fn run(&mut self, command: &str, timeout_ms: Option<u64>) -> Result<(String, String, i32), String> {
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
let timeout = Duration::from_millis(timeout_ms);
async fn spawn(&mut self) -> Result<(), String> {
#[cfg(windows)]
{
let mut cmd = Command::new("powershell.exe");
cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]);
run_captured(cmd, timeout).await
}
let (program, args) = ("cmd.exe", vec!["/Q"]);
#[cfg(not(windows))]
{
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
run_captured(cmd, timeout).await
let (program, args) = ("sh", vec!["-s"]);
let mut cmd = tokio::process::Command::new(program);
for arg in &args {
cmd.arg(arg);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn shell '{program}': {e}"))?;
let stdin = child.stdin.take().ok_or("no stdin")?;
let stdout = child.stdout.take().ok_or("no stdout")?;
let stderr = child.stderr.take().ok_or("no stderr")?;
self.child = Some(ShellProcess {
_child: child,
stdin,
stdout_lines: tokio::sync::Mutex::new(BufReader::new(stdout)),
stderr_lines: tokio::sync::Mutex::new(BufReader::new(stderr)),
});
Ok(())
}
/// Run a command in the persistent shell, returning (stdout, stderr, exit_code).
/// exit_code is always 0 for intermediate commands; we read the exit code via `echo %ERRORLEVEL%`.
pub async fn run(&mut self, command: &str) -> Result<(String, String, i32), String> {
// Restart shell if it died
if self.child.is_none() {
self.spawn().await?;
}
let result = self.run_inner(command).await;
match result {
Ok(r) => Ok(r),
Err(e) => {
// Shell probably died — drop it and report error
warn!("Shell error, will respawn next time: {e}");
self.child = None;
Err(e)
}
}
}
}
async fn run_captured(
mut cmd: Command,
timeout: Duration,
) -> Result<(String, String, i32), String> {
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
async fn run_inner(&mut self, command: &str) -> Result<(String, String, i32), String> {
let shell = self.child.as_mut().ok_or("no shell")?;
let child = cmd.spawn()
.map_err(|e| format!("Failed to spawn process: {e}"))?;
// Write command + sentinel echo to stdin
#[cfg(windows)]
let cmd_line = format!("{command}\r\necho {SENTINEL}%ERRORLEVEL%\r\n");
#[cfg(not(windows))]
let cmd_line = format!("{command}\necho {SENTINEL}$?\n");
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(out)) => Ok((
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)),
Ok(Err(e)) => Err(format!("Process wait failed: {e}")),
Err(_) => Err(format!("Command timed out after {}ms", timeout.as_millis())),
debug!("Shell input: {cmd_line:?}");
shell
.stdin
.write_all(cmd_line.as_bytes())
.await
.map_err(|e| format!("Failed to write to shell stdin: {e}"))?;
shell
.stdin
.flush()
.await
.map_err(|e| format!("Failed to flush shell stdin: {e}"))?;
// Read stdout until we see the sentinel line
let mut stdout_buf = String::new();
#[allow(unused_assignments)]
let mut exit_code = 0i32;
let timeout = tokio::time::Duration::from_millis(OUTPUT_TIMEOUT_MS);
{
let mut reader = shell.stdout_lines.lock().await;
loop {
let mut line = String::new();
let read_fut = reader.read_line(&mut line);
match tokio::time::timeout(timeout, read_fut).await {
Ok(Ok(0)) => {
return Err("Shell stdout EOF — process likely died".to_string());
}
Ok(Ok(_)) => {
debug!("stdout line: {line:?}");
if line.trim_end().starts_with(SENTINEL) {
// Parse exit code from sentinel line
let code_str = line.trim_end().trim_start_matches(SENTINEL);
exit_code = code_str.trim().parse().unwrap_or(0);
break;
} else {
stdout_buf.push_str(&line);
}
}
Ok(Err(e)) => {
return Err(format!("Shell stdout read error: {e}"));
}
Err(_) => {
return Err(format!(
"Shell stdout timed out after {}ms waiting for command to finish.\nCommand: {command}\nOutput so far: {stdout_buf}",
OUTPUT_TIMEOUT_MS
));
}
}
}
}
// Drain available stderr (non-blocking)
let mut stderr_buf = String::new();
{
let mut reader = shell.stderr_lines.lock().await;
let drain_timeout = tokio::time::Duration::from_millis(100);
loop {
let mut line = String::new();
match tokio::time::timeout(drain_timeout, reader.read_line(&mut line)).await {
Ok(Ok(0)) | Err(_) => break,
Ok(Ok(_)) => stderr_buf.push_str(&line),
Ok(Err(_)) => break,
}
}
}
Ok((stdout_buf, stderr_buf, exit_code))
}
}

View file

@ -1,27 +1,18 @@
use helios_common::protocol::{sanitize_label, WindowInfo};
use std::collections::HashMap;
use helios_common::protocol::WindowInfo;
// ── Windows implementation ──────────────────────────────────────────────────
#[cfg(windows)]
mod win_impl {
use super::*;
use std::sync::Mutex;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED};
use windows::Win32::UI::WindowsAndMessaging::{
BringWindowToTop, EnumWindows, GetWindowTextW,
IsWindowVisible, SetForegroundWindow, ShowWindow,
SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE,
BringWindowToTop, EnumWindows, GetWindowTextW, IsWindowVisible, SetForegroundWindow,
ShowWindow, SW_MAXIMIZE, SW_MINIMIZE, SHOW_WINDOW_CMD,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{
keybd_event, KEYEVENTF_KEYUP, VK_MENU,
};
use windows::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT,
PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows::Win32::System::ProcessStatus::GetModuleBaseNameW;
// 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);
@ -39,123 +30,25 @@ mod win_impl {
list
}
fn is_cloaked(hwnd: HWND) -> bool {
let mut cloaked: u32 = 0;
unsafe {
DwmGetWindowAttribute(
hwnd,
DWMWA_CLOAKED,
&mut cloaked as *mut u32 as *mut _,
std::mem::size_of::<u32>() as u32,
).is_err() == false && cloaked != 0
}
}
fn hwnd_title(hwnd: HWND) -> String {
let mut buf = [0u16; 512];
let len = unsafe { GetWindowTextW(hwnd, &mut buf) };
String::from_utf16_lossy(&buf[..len as usize])
}
/// Get the process name (exe without extension) for a window handle.
/// Tries `GetModuleBaseNameW` first, then `QueryFullProcessImageNameW`
/// (which works for elevated processes with `PROCESS_QUERY_LIMITED_INFORMATION`).
fn hwnd_process_name(hwnd: HWND) -> Option<String> {
unsafe {
let mut pid: u32 = 0;
windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId(hwnd, Some(&mut pid));
if pid == 0 {
return None;
}
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
// Try GetModuleBaseNameW first
let mut buf = [0u16; 260];
let len = GetModuleBaseNameW(handle, None, &mut buf);
if len > 0 {
let _ = windows::Win32::Foundation::CloseHandle(handle);
let name = String::from_utf16_lossy(&buf[..len as usize]);
return Some(strip_exe_ext(&name));
}
// Fallback: QueryFullProcessImageNameW (works for elevated processes)
let mut buf2 = [0u16; 1024];
let mut size = buf2.len() as u32;
let ok = QueryFullProcessImageNameW(handle, PROCESS_NAME_FORMAT(0), windows::core::PWSTR(buf2.as_mut_ptr()), &mut size);
let _ = windows::Win32::Foundation::CloseHandle(handle);
if ok.is_ok() && size > 0 {
let full_path = String::from_utf16_lossy(&buf2[..size as usize]);
// Extract filename from full path
let filename = full_path.rsplit('\\').next().unwrap_or(&full_path);
return Some(strip_exe_ext(filename));
}
None
}
}
fn strip_exe_ext(name: &str) -> String {
name.strip_suffix(".exe")
.or(name.strip_suffix(".EXE"))
.unwrap_or(name)
.to_string()
}
pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
let hwnds = get_all_hwnds();
// Collect visible windows with non-empty titles
let mut raw_windows: Vec<(HWND, String, String)> = Vec::new();
for hwnd in &hwnds {
let visible = unsafe { IsWindowVisible(*hwnd).as_bool() };
if !visible {
continue;
}
if is_cloaked(*hwnd) {
continue;
}
let title = hwnd_title(*hwnd);
let mut windows = Vec::new();
for hwnd in hwnds {
let visible = unsafe { IsWindowVisible(hwnd).as_bool() };
let title = hwnd_title(hwnd);
if title.is_empty() {
continue;
}
// "Program Manager" is always the Windows desktop shell, never a real window
if title.trim().eq_ignore_ascii_case("program manager") {
continue;
}
let process_name = hwnd_process_name(*hwnd).unwrap_or_default();
let proc_lower = process_name.to_lowercase();
// ApplicationFrameHost is a UWP container — always a duplicate of the real app window
// MsEdgeWebView2 is an embedded browser component, never a standalone user window
if proc_lower == "applicationframehost" || proc_lower == "msedgewebview2" {
continue;
}
raw_windows.push((*hwnd, title, process_name));
}
// Generate labels with dedup numbering
let mut label_index: HashMap<String, usize> = HashMap::new();
let mut windows = Vec::new();
for (hwnd, title, proc_name) in raw_windows {
let base = if proc_name.is_empty() {
sanitize_label(&title)
} else {
sanitize_label(&proc_name)
};
if base.is_empty() {
continue;
}
let idx = label_index.entry(base.clone()).or_insert(0);
*idx += 1;
let label = if *idx == 1 {
base.clone()
} else {
format!("{}{}", base, idx)
};
windows.push(WindowInfo {
id: hwnd.0 as u64,
title,
label,
visible: true,
visible,
});
}
Ok(windows)
@ -175,17 +68,12 @@ mod win_impl {
Ok(())
}
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);
ShowWindow(hwnd, SW_RESTORE);
BringWindowToTop(hwnd).ok();
SetForegroundWindow(hwnd);
}
pub fn focus_window(window_id: u64) -> Result<(), String> {
let hwnd = HWND(window_id as isize);
unsafe { force_foreground(hwnd); }
unsafe {
BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?;
SetForegroundWindow(hwnd);
}
Ok(())
}
@ -193,7 +81,8 @@ mod win_impl {
let hwnd = HWND(window_id as isize);
unsafe {
ShowWindow(hwnd, SW_MAXIMIZE);
force_foreground(hwnd);
BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?;
SetForegroundWindow(hwnd);
}
Ok(())
}

View file

@ -1,74 +1,36 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Information about a single window on the client machine.
/// `label` is a human-readable, lowercase identifier (e.g. "google_chrome", "discord").
/// Information about a single window on the client machine
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo {
pub id: u64,
pub title: String,
pub label: String,
pub visible: bool,
}
/// Validate a device/window label: lowercase, no whitespace, only a-z 0-9 - _
pub fn is_valid_label(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// Convert an arbitrary string into a valid label.
/// Lowercase, replace whitespace and invalid chars with '_', collapse runs.
pub fn sanitize_label(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut prev_underscore = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
result.push(c.to_ascii_lowercase());
prev_underscore = false;
} else if c == '-' {
result.push('-');
prev_underscore = false;
} else {
// Replace whitespace and other chars with _
if !prev_underscore && !result.is_empty() {
result.push('_');
prev_underscore = true;
}
}
}
// Trim trailing _
result.trim_end_matches('_').to_string()
}
/// Messages sent from the relay server to a connected client
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
/// Request a full-screen screenshot
/// Request a screenshot from the client
ScreenshotRequest { request_id: Uuid },
/// Capture a specific window by its HWND
WindowScreenshotRequest { request_id: Uuid, window_id: u64 },
/// Fetch the last N lines of the client log file
LogsRequest { request_id: Uuid, lines: u32 },
/// Show a MessageBox on the client asking the user to do something
PromptRequest {
request_id: Uuid,
message: String,
title: Option<String>,
},
/// Show a non-blocking notification to the user (fire-and-forget)
InformRequest {
request_id: Uuid,
message: String,
title: Option<String>,
},
/// Execute a shell command on the client
ExecRequest {
request_id: Uuid,
command: String,
timeout_ms: Option<u64>,
},
/// Simulate a mouse click
ClickRequest {
request_id: Uuid,
x: i32,
y: i32,
button: MouseButton,
},
/// Type text on the client
TypeRequest {
request_id: Uuid,
text: String,
},
/// Acknowledge a client message
Ack { request_id: Uuid },
@ -85,39 +47,14 @@ pub enum ServerMessage {
FocusWindowRequest { request_id: Uuid, window_id: u64 },
/// Maximize a window and bring it to the foreground
MaximizeAndFocusRequest { request_id: Uuid, window_id: u64 },
/// Request client version info
VersionRequest { request_id: Uuid },
/// Upload a file to the client
UploadRequest {
request_id: Uuid,
path: String,
content_base64: String,
},
/// Download a file from the client
DownloadRequest {
request_id: Uuid,
path: String,
},
/// Launch a program on the client (fire-and-forget)
RunRequest {
request_id: Uuid,
program: String,
args: Vec<String>,
},
/// Get the contents of the client's clipboard
ClipboardGetRequest { request_id: Uuid },
/// Set the contents of the client's clipboard
ClipboardSetRequest { request_id: Uuid, text: String },
/// Request client to self-update and restart
UpdateRequest { request_id: Uuid },
}
/// Messages sent from the client to the relay server
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
/// Client registers itself with its device label
Hello { label: String },
/// Client registers itself with optional display name
Hello { label: Option<String> },
/// Response to a screenshot request — base64-encoded PNG
ScreenshotResponse {
request_id: Uuid,
@ -132,7 +69,7 @@ pub enum ClientMessage {
stderr: String,
exit_code: i32,
},
/// Generic acknowledgement
/// Generic acknowledgement for click/type/minimize-all/focus/maximize
Ack { request_id: Uuid },
/// Client error response
Error {
@ -144,64 +81,32 @@ pub enum ClientMessage {
request_id: Uuid,
windows: Vec<WindowInfo>,
},
/// Response to a version request
VersionResponse {
request_id: Uuid,
version: String,
commit: String,
},
LogsResponse {
request_id: Uuid,
content: String,
log_path: String,
},
/// Response to a download request
DownloadResponse {
request_id: Uuid,
content_base64: String,
size: u64,
},
/// Response to a clipboard-get request
ClipboardGetResponse { request_id: Uuid, text: String },
/// Response to a prompt request
PromptResponse { request_id: Uuid, answer: String },
/// Response to an update request
UpdateResponse {
request_id: Uuid,
success: bool,
message: String,
},
}
/// Mouse button variants
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MouseButton {
Left,
Right,
Middle,
}
impl Default for MouseButton {
fn default() -> Self {
MouseButton::Left
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_labels() {
assert!(is_valid_label("moritz_pc"));
assert!(is_valid_label("my-desktop"));
assert!(is_valid_label("pc01"));
assert!(!is_valid_label("Moritz PC"));
assert!(!is_valid_label(""));
assert!(!is_valid_label("has spaces"));
assert!(!is_valid_label("UPPER"));
}
#[test]
fn test_sanitize_label() {
assert_eq!(sanitize_label("Moritz PC"), "moritz_pc");
assert_eq!(sanitize_label("My Desktop!!"), "my_desktop");
assert_eq!(sanitize_label("hello-world"), "hello-world");
assert_eq!(sanitize_label("DESKTOP-ABC123"), "desktop-abc123");
}
#[test]
fn test_server_message_serialization() {
let msg = ServerMessage::ExecRequest {
request_id: Uuid::nil(),
command: "echo hello".into(),
timeout_ms: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("exec_request"));
@ -210,9 +115,25 @@ mod tests {
#[test]
fn test_client_message_serialization() {
let msg = ClientMessage::Hello { label: "test-pc".into() };
let msg = ClientMessage::Hello { label: Some("test-pc".into()) };
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("hello"));
assert!(json.contains("test-pc"));
}
#[test]
fn test_roundtrip() {
let msg = ClientMessage::ExecResponse {
request_id: Uuid::nil(),
stdout: "hello\n".into(),
stderr: String::new(),
exit_code: 0,
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: ClientMessage = serde_json::from_str(&json).unwrap();
match decoded {
ClientMessage::ExecResponse { exit_code, .. } => assert_eq!(exit_code, 0),
_ => panic!("wrong variant"),
}
}
}

View file

@ -1,10 +1,10 @@
[package]
name = "helios-remote-relay"
name = "helios-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "helios-remote-relay"
name = "helios-server"
path = "src/main.rs"
[dependencies]
@ -22,4 +22,3 @@ tokio-tungstenite = "0.21"
futures-util = "0.3"
dashmap = "5"
anyhow = "1"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }

View file

@ -1,11 +0,0 @@
fn main() {
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
let hash = hash.trim();
println!("cargo:rustc-env=GIT_COMMIT={}", if hash.is_empty() { "unknown" } else { hash });
println!("cargo:rerun-if-changed=.git/HEAD");
}

View file

@ -1,6 +1,6 @@
use std::time::Duration;
use axum::{
extract::{Path, Query, State},
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use tracing::error;
use helios_common::protocol::{ClientMessage, ServerMessage};
use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage};
use crate::AppState;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@ -21,29 +21,33 @@ pub struct ErrorBody {
pub error: String,
}
fn not_found(label: &str) -> (StatusCode, Json<ErrorBody>) {
fn not_found(session_id: &str) -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::NOT_FOUND,
Json(ErrorBody {
error: format!("Device '{label}' not found or not connected"),
error: format!("Session '{session_id}' not found or not connected"),
}),
)
}
fn timeout_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
fn timeout_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorBody {
error: format!("Timed out waiting for client response (device='{label}', op='{op}')"),
error: format!(
"Timed out waiting for client response (session='{session_id}', op='{op}')"
),
}),
)
}
fn send_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::BAD_GATEWAY,
Json(ErrorBody {
error: format!("Failed to send command to client — may have disconnected (device='{label}', op='{op}')"),
error: format!(
"Failed to send command to client — client may have disconnected (session='{session_id}', op='{op}')"
),
}),
)
}
@ -52,477 +56,282 @@ fn send_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
async fn dispatch<F>(
state: &AppState,
label: &str,
session_id: &str,
op: &str,
make_msg: F,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where
F: FnOnce(Uuid) -> ServerMessage,
{
dispatch_with_timeout(state, label, op, make_msg, REQUEST_TIMEOUT).await
}
let id = session_id.parse::<Uuid>().map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(ErrorBody {
error: format!("Invalid session id: '{session_id}'"),
}),
)
})?;
async fn dispatch_with_timeout<F>(
state: &AppState,
label: &str,
op: &str,
make_msg: F,
timeout: Duration,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where
F: FnOnce(Uuid) -> ServerMessage,
{
let tx = state
.sessions
.get_cmd_tx(label)
.ok_or_else(|| not_found(label))?;
.get_cmd_tx(&id)
.ok_or_else(|| not_found(session_id))?;
let request_id = Uuid::new_v4();
let rx = state.sessions.register_pending(request_id);
let msg = make_msg(request_id);
tx.send(msg).await.map_err(|e| {
error!("Channel send failed for device={label}, op={op}: {e}");
send_error(label, op)
error!("Channel send failed for session={session_id}, op={op}: {e}");
send_error(session_id, op)
})?;
match tokio::time::timeout(timeout, rx).await {
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(send_error(label, op)),
Err(_) => Err(timeout_error(label, op)),
Ok(Err(_)) => Err(send_error(session_id, op)),
Err(_) => Err(timeout_error(session_id, op)),
}
}
// ── Handlers ─────────────────────────────────────────────────────────────────
/// GET /devices — list all connected clients
pub async fn list_devices(State(state): State<AppState>) -> Json<serde_json::Value> {
let devices = state.sessions.list();
Json(serde_json::json!({ "devices": devices }))
/// GET /sessions — list all connected clients
pub async fn list_sessions(State(state): State<AppState>) -> Json<serde_json::Value> {
let sessions = state.sessions.list();
Json(serde_json::json!({ "sessions": sessions }))
}
/// POST /devices/:label/screenshot — full screen screenshot
/// POST /sessions/:id/screenshot
pub async fn request_screenshot(
Path(label): Path<String>,
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "screenshot", |rid| {
match dispatch(&state, &session_id, "screenshot", |rid| {
ServerMessage::ScreenshotRequest { request_id: rid }
}).await {
Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => (
})
.await
{
Ok(ClientMessage::ScreenshotResponse {
image_base64,
width,
height,
..
}) => (
StatusCode::OK,
Json(serde_json::json!({ "image_base64": image_base64, "width": width, "height": height })),
).into_response(),
Json(serde_json::json!({
"image_base64": image_base64,
"width": width,
"height": height,
})),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
)
.into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/:window_id/screenshot
pub async fn window_screenshot(
Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "window_screenshot", |rid| {
ServerMessage::WindowScreenshotRequest { request_id: rid, window_id }
}).await {
Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "image_base64": image_base64, "width": width, "height": height })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/logs?lines=100
pub async fn logs(
Path(label): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
State(state): State<AppState>,
) -> impl IntoResponse {
let lines: u32 = params.get("lines").and_then(|v| v.parse().ok()).unwrap_or(100);
match dispatch(&state, &label, "logs", |rid| {
ServerMessage::LogsRequest { request_id: rid, lines }
}).await {
Ok(ClientMessage::LogsResponse { content, log_path, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "content": content, "log_path": log_path, "lines": lines })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/exec
/// POST /sessions/:id/exec
#[derive(Deserialize)]
pub struct ExecBody {
pub command: String,
pub timeout_ms: Option<u64>,
}
pub async fn request_exec(
Path(label): Path<String>,
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<ExecBody>,
) -> impl IntoResponse {
let server_timeout = body.timeout_ms
.map(|ms| Duration::from_millis(ms + 5_000))
.unwrap_or(REQUEST_TIMEOUT);
match dispatch_with_timeout(&state, &label, "exec", |rid| ServerMessage::ExecRequest {
match dispatch(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
request_id: rid,
command: body.command.clone(),
timeout_ms: body.timeout_ms,
}, server_timeout).await {
Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => (
})
.await
{
Ok(ClientMessage::ExecResponse {
stdout,
stderr,
exit_code,
..
}) => (
StatusCode::OK,
Json(serde_json::json!({ "stdout": stdout, "stderr": stderr, "exit_code": exit_code })),
).into_response(),
Json(serde_json::json!({
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
})),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
)
.into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/windows
pub async fn list_windows(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "list_windows", |rid| {
ServerMessage::ListWindowsRequest { request_id: rid }
}).await {
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "windows": windows })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/minimize-all
pub async fn minimize_all(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "minimize_all", |rid| {
ServerMessage::MinimizeAllRequest { request_id: rid }
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/:window_id/focus
pub async fn focus_window(
Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "focus_window", |rid| {
ServerMessage::FocusWindowRequest { request_id: rid, window_id }
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/:window_id/maximize
pub async fn maximize_and_focus(
Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "maximize_and_focus", |rid| {
ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /version — server version (public, no auth)
pub async fn server_version() -> impl IntoResponse {
Json(serde_json::json!({
"commit": env!("GIT_COMMIT"),
}))
}
/// GET /devices/:label/version — client version
pub async fn client_version(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "version", |rid| {
ServerMessage::VersionRequest { request_id: rid }
}).await {
Ok(ClientMessage::VersionResponse { version, commit, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "version": version, "commit": commit })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/upload
/// POST /sessions/:id/click
#[derive(Deserialize)]
pub struct UploadBody {
pub path: String,
pub content_base64: String,
}
pub async fn upload_file(
Path(label): Path<String>,
State(state): State<AppState>,
Json(body): Json<UploadBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "upload", |rid| ServerMessage::UploadRequest {
request_id: rid,
path: body.path.clone(),
content_base64: body.content_base64.clone(),
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/download?path=...
#[derive(Deserialize)]
pub struct DownloadQuery {
pub path: String,
}
pub async fn download_file(
Path(label): Path<String>,
State(state): State<AppState>,
Query(query): Query<DownloadQuery>,
) -> impl IntoResponse {
match dispatch(&state, &label, "download", |rid| ServerMessage::DownloadRequest {
request_id: rid,
path: query.path.clone(),
}).await {
Ok(ClientMessage::DownloadResponse { content_base64, size, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "content_base64": content_base64, "size": size })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/run
#[derive(Deserialize)]
pub struct RunBody {
pub program: String,
pub struct ClickBody {
pub x: i32,
pub y: i32,
#[serde(default)]
pub args: Vec<String>,
pub button: MouseButton,
}
pub async fn run_program(
Path(label): Path<String>,
pub async fn request_click(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<RunBody>,
Json(body): Json<ClickBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "run", |rid| ServerMessage::RunRequest {
match dispatch(&state, &session_id, "click", |rid| ServerMessage::ClickRequest {
request_id: rid,
program: body.program.clone(),
args: body.args.clone(),
}).await {
x: body.x,
y: body.y,
button: body.button.clone(),
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/clipboard
pub async fn clipboard_get(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "clipboard_get", |rid| {
ServerMessage::ClipboardGetRequest { request_id: rid }
}).await {
Ok(ClientMessage::ClipboardGetResponse { text, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "text": text })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/clipboard
/// POST /sessions/:id/type
#[derive(Deserialize)]
pub struct ClipboardSetBody {
pub struct TypeBody {
pub text: String,
}
pub async fn clipboard_set(
Path(label): Path<String>,
pub async fn request_type(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<ClipboardSetBody>,
Json(body): Json<TypeBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "clipboard_set", |rid| {
ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
}).await {
match dispatch(&state, &session_id, "type", |rid| ServerMessage::TypeRequest {
request_id: rid,
text: body.text.clone(),
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /relay/update — self-update the relay binary and restart the service
pub async fn relay_update() -> impl IntoResponse {
tokio::spawn(async {
// Give the HTTP response time to be sent before we restart
tokio::time::sleep(Duration::from_millis(800)).await;
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-relay-linux";
let bytes = match reqwest::get(url).await {
Ok(r) => match r.bytes().await {
Ok(b) => b,
Err(e) => {
error!("relay update: failed to read response body: {e}");
return;
}
},
Err(e) => {
error!("relay update: download failed: {e}");
return;
}
};
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
error!("relay update: current_exe: {e}");
return;
}
};
let tmp = exe.with_extension("new");
if let Err(e) = std::fs::write(&tmp, &bytes) {
error!("relay update: write tmp failed: {e}");
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
}
if let Err(e) = std::fs::rename(&tmp, &exe) {
error!("relay update: rename failed: {e}");
return;
}
let _ = std::process::Command::new("systemctl")
.args(["restart", "helios-remote"])
.spawn();
});
(
axum::http::StatusCode::OK,
Json(serde_json::json!({ "ok": true, "message": "update triggered, relay restarting..." })),
)
}
/// POST /devices/:label/update — trigger client self-update
pub async fn client_update(
Path(label): Path<String>,
/// GET /sessions/:id/windows
pub async fn list_windows(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch_with_timeout(&state, &label, "update", |rid| {
ServerMessage::UpdateRequest { request_id: rid }
}, Duration::from_secs(60)).await {
Ok(ClientMessage::UpdateResponse { success, message, .. }) => (
match dispatch(&state, &session_id, "list_windows", |rid| {
ServerMessage::ListWindowsRequest { request_id: rid }
})
.await
{
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "success": success, "message": message })),
).into_response(),
Ok(ClientMessage::Ack { .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "success": true, "message": "update acknowledged" })),
).into_response(),
Json(serde_json::json!({ "windows": windows })),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "success": false, "message": message })),
).into_response(),
Json(serde_json::json!({ "error": message })),
)
.into_response(),
Ok(_) => (
StatusCode::OK,
Json(serde_json::json!({ "success": true, "message": "acknowledged" })),
).into_response(),
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/inform
pub async fn inform_user(
Path(label): Path<String>,
/// POST /sessions/:id/windows/minimize-all
pub async fn minimize_all(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<PromptBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "inform", |rid| ServerMessage::InformRequest {
request_id: rid,
message: body.message.clone(),
title: body.title.clone(),
}).await {
match dispatch(&state, &session_id, "minimize_all", |rid| {
ServerMessage::MinimizeAllRequest { request_id: rid }
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/prompt
/// POST /sessions/:id/windows/:window_id/focus
pub async fn focus_window(
Path((session_id, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &session_id, "focus_window", |rid| {
ServerMessage::FocusWindowRequest { request_id: rid, window_id }
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /sessions/:id/windows/:window_id/maximize
pub async fn maximize_and_focus(
Path((session_id, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &session_id, "maximize_and_focus", |rid| {
ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /sessions/:id/label
#[derive(Deserialize)]
pub struct PromptBody {
pub message: String,
pub title: Option<String>,
pub struct LabelBody {
pub label: String,
}
pub async fn prompt_user(
Path(label): Path<String>,
pub async fn set_label(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<PromptBody>,
Json(body): Json<LabelBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "prompt", |rid| ServerMessage::PromptRequest {
request_id: rid,
message: body.message.clone(),
title: body.title.clone(),
}).await {
Ok(ClientMessage::PromptResponse { answer, .. }) => {
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "answer": answer }))).into_response()
let id = match session_id.parse::<Uuid>() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": format!("Invalid session id: '{session_id}'") })),
)
.into_response()
}
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
};
if state.sessions.set_label(&id, body.label.clone()) {
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
} else {
not_found(&session_id).into_response()
}
}

View file

@ -30,9 +30,6 @@ async fn main() -> anyhow::Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
const GIT_COMMIT: &str = env!("GIT_COMMIT");
info!("helios-server ({GIT_COMMIT})");
let api_key = std::env::var("HELIOS_API_KEY")
.unwrap_or_else(|_| "dev-secret".to_string());
@ -45,30 +42,20 @@ async fn main() -> anyhow::Result<()> {
};
let protected = Router::new()
.route("/devices", get(api::list_devices))
.route("/devices/:label/screenshot", post(api::request_screenshot))
.route("/devices/:label/exec", post(api::request_exec))
.route("/devices/:label/prompt", post(api::prompt_user))
.route("/devices/:label/inform", post(api::inform_user))
.route("/devices/:label/windows", get(api::list_windows))
.route("/devices/:label/windows/minimize-all", post(api::minimize_all))
.route("/devices/:label/logs", get(api::logs))
.route("/devices/:label/windows/:window_id/screenshot", post(api::window_screenshot))
.route("/devices/:label/windows/:window_id/focus", post(api::focus_window))
.route("/devices/:label/windows/:window_id/maximize", post(api::maximize_and_focus))
.route("/devices/:label/version", get(api::client_version))
.route("/devices/:label/upload", post(api::upload_file))
.route("/devices/:label/download", get(api::download_file))
.route("/devices/:label/run", post(api::run_program))
.route("/devices/:label/clipboard", get(api::clipboard_get))
.route("/devices/:label/clipboard", post(api::clipboard_set))
.route("/relay/update", post(api::relay_update))
.route("/devices/:label/update", post(api::client_update))
.route("/sessions", get(api::list_sessions))
.route("/sessions/:id/screenshot", post(api::request_screenshot))
.route("/sessions/:id/exec", post(api::request_exec))
.route("/sessions/:id/click", post(api::request_click))
.route("/sessions/:id/type", post(api::request_type))
.route("/sessions/:id/label", post(api::set_label))
.route("/sessions/:id/windows", get(api::list_windows))
.route("/sessions/:id/windows/minimize-all", post(api::minimize_all))
.route("/sessions/:id/windows/:window_id/focus", post(api::focus_window))
.route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus))
.layer(middleware::from_fn_with_state(state.clone(), require_api_key));
let app = Router::new()
.route("/ws", get(ws_handler::ws_upgrade))
.route("/version", get(api::server_version))
.merge(protected)
.with_state(state);

View file

@ -4,11 +4,11 @@ use uuid::Uuid;
use serde::Serialize;
use helios_common::protocol::{ClientMessage, ServerMessage};
/// Represents one connected remote client.
/// The device label is the sole identifier — no session UUIDs exposed externally.
/// Represents one connected remote client
#[derive(Debug, Clone)]
pub struct Session {
pub label: String,
pub id: Uuid,
pub label: Option<String>,
/// Channel to send commands to the WS handler for this session
pub cmd_tx: mpsc::Sender<ServerMessage>,
}
@ -16,20 +16,22 @@ pub struct Session {
/// Serializable view of a session for the REST API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
pub label: String,
pub id: Uuid,
pub label: Option<String>,
}
impl From<&Session> for SessionInfo {
fn from(s: &Session) -> Self {
SessionInfo {
id: s.id,
label: s.label.clone(),
}
}
}
pub struct SessionStore {
/// Active sessions keyed by device label
sessions: DashMap<String, Session>,
/// Active sessions by ID
sessions: DashMap<Uuid, Session>,
/// Pending request callbacks by request_id
pending: DashMap<Uuid, oneshot::Sender<ClientMessage>>,
}
@ -43,15 +45,24 @@ impl SessionStore {
}
pub fn insert(&self, session: Session) {
self.sessions.insert(session.label.clone(), session);
self.sessions.insert(session.id, session);
}
pub fn remove(&self, label: &str) {
self.sessions.remove(label);
pub fn remove(&self, id: &Uuid) {
self.sessions.remove(id);
}
pub fn get_cmd_tx(&self, label: &str) -> Option<mpsc::Sender<ServerMessage>> {
self.sessions.get(label).map(|s| s.cmd_tx.clone())
pub fn get_cmd_tx(&self, id: &Uuid) -> Option<mpsc::Sender<ServerMessage>> {
self.sessions.get(id).map(|s| s.cmd_tx.clone())
}
pub fn set_label(&self, id: &Uuid, label: String) -> bool {
if let Some(mut s) = self.sessions.get_mut(id) {
s.label = Some(label);
true
} else {
false
}
}
pub fn list(&self) -> Vec<SessionInfo> {
@ -66,6 +77,7 @@ impl SessionStore {
}
/// Deliver a client response to the waiting request handler.
/// Returns true if the request was found and resolved.
pub fn resolve_pending(&self, request_id: Uuid, msg: ClientMessage) -> bool {
if let Some((_, tx)) = self.pending.remove(&request_id) {
let _ = tx.send(msg);

View file

@ -5,6 +5,7 @@ use axum::{
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use uuid::Uuid;
use tracing::{debug, error, info, warn};
use helios_common::protocol::{ClientMessage, ServerMessage};
@ -18,57 +19,32 @@ pub async fn ws_upgrade(
}
async fn handle_socket(socket: WebSocket, state: AppState) {
let session_id = Uuid::new_v4();
let (cmd_tx, mut cmd_rx) = mpsc::channel::<ServerMessage>(64);
let (mut ws_tx, mut ws_rx) = socket.split();
// Wait for the Hello message to get the device label
let label = loop {
match ws_rx.next().await {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Hello { label }) => {
if label.is_empty() {
warn!("Client sent empty label, disconnecting");
return;
}
break label;
}
Ok(_) => {
warn!("Expected Hello as first message, got something else");
return;
}
Err(e) => {
warn!("Invalid JSON on handshake: {e}");
return;
}
}
}
Some(Ok(Message::Close(_))) | None => return,
_ => continue,
}
};
// Register session by label
// Register session (label filled in on Hello)
let session = Session {
label: label.clone(),
id: session_id,
label: None,
cmd_tx,
};
state.sessions.insert(session);
info!("Client connected: device={label}");
info!("Client connected: session={session_id}");
let (mut ws_tx, mut ws_rx) = socket.split();
// Spawn task: forward server commands → WS
let label_clone = label.clone();
let send_task = tokio::spawn(async move {
while let Some(msg) = cmd_rx.recv().await {
match serde_json::to_string(&msg) {
Ok(json) => {
if let Err(e) = ws_tx.send(Message::Text(json.into())).await {
error!("WS send error for device={label_clone}: {e}");
error!("WS send error for session={session_id}: {e}");
break;
}
}
Err(e) => {
error!("Serialization error for device={label_clone}: {e}");
error!("Serialization error for session={session_id}: {e}");
}
}
}
@ -79,48 +55,45 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
match result {
Ok(Message::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(msg) => handle_client_message(&label, msg, &state).await,
Ok(msg) => handle_client_message(session_id, msg, &state).await,
Err(e) => {
warn!("Invalid JSON from device={label}: {e}");
warn!("Invalid JSON from session={session_id}: {e} | raw={text}");
}
}
}
Ok(Message::Close(_)) => {
info!("Client disconnected gracefully: device={label}");
info!("Client disconnected gracefully: session={session_id}");
break;
}
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {}
Err(e) => {
error!("WS receive error for device={label}: {e}");
error!("WS receive error for session={session_id}: {e}");
break;
}
}
}
send_task.abort();
state.sessions.remove(&label);
info!("Session cleaned up: device={label}");
state.sessions.remove(&session_id);
info!("Session cleaned up: session={session_id}");
}
async fn handle_client_message(label: &str, msg: ClientMessage, state: &AppState) {
async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &AppState) {
match &msg {
ClientMessage::Hello { .. } => {
debug!("Duplicate Hello from device={label}, ignoring");
ClientMessage::Hello { label } => {
if let Some(lbl) = label {
state.sessions.set_label(&session_id, lbl.clone());
}
debug!("Hello from session={session_id}, label={label:?}");
}
ClientMessage::ScreenshotResponse { request_id, .. }
| ClientMessage::ExecResponse { request_id, .. }
| ClientMessage::ListWindowsResponse { request_id, .. }
| ClientMessage::VersionResponse { request_id, .. }
| ClientMessage::LogsResponse { request_id, .. }
| ClientMessage::DownloadResponse { request_id, .. }
| ClientMessage::ClipboardGetResponse { request_id, .. }
| ClientMessage::PromptResponse { request_id, .. }
| ClientMessage::UpdateResponse { request_id, .. }
| ClientMessage::Ack { request_id }
| ClientMessage::Error { request_id, .. } => {
let rid = *request_id;
if !state.sessions.resolve_pending(rid, msg) {
warn!("No pending request for request_id={rid} (device={label})");
warn!("No pending request for request_id={rid} (session={session_id})");
}
}
}

View file

@ -1,5 +1,5 @@
# Helios Remote - Install Script
# Downloads and starts the helios-remote Windows client.
# Downloads and starts the helios-remote Windows client
#
# IMPORTANT: Windows Defender may block the executable.
# If the download or launch fails, disable real-time protection first (run as Admin):
@ -9,17 +9,11 @@
$ErrorActionPreference = "Stop"
$url = "https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe"
$dest = "$env:USERPROFILE\Desktop\Helios Remote.exe"
$url = "https://github.com/agent-helios/helios-remote/releases/latest/download/helios-remote-client-windows.exe"
$dest = "$env:TEMP\helios-remote.exe"
Write-Host "Downloading helios-remote client..."
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
curl.exe -L -o $dest $url
} else {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
(New-Object Net.WebClient).DownloadFile($url, $dest)
}
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
Write-Host "Starting..."
Start-Process -FilePath $dest
Start-Process -FilePath $dest -NoNewWindow