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: with:
targets: x86_64-pc-windows-gnu targets: x86_64-pc-windows-gnu
- name: Install MinGW cross-compiler and tools - name: Install MinGW cross-compiler
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64
- name: Cache dependencies - name: Cache dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@ -47,7 +47,7 @@ jobs:
- name: Build Windows client (cross-compile) - name: Build Windows client (cross-compile)
run: | 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: env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
@ -55,12 +55,12 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: helios-remote-client-windows 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 if-no-files-found: error
- name: Rename exe for release - name: Rename exe for release
if: github.ref == 'refs/heads/master' 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) - name: Publish rolling release (latest)
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
@ -73,126 +73,3 @@ jobs:
files: helios-remote-client-windows.exe files: helios-remote-client-windows.exe
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 **/*.rs.bk
.env .env
*.pdb *.pdb
remote

View file

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

134
README.md
View file

@ -4,7 +4,7 @@
<img src="assets/logo.png" width="150" alt="helios-remote logo" /> <img src="assets/logo.png" width="150" alt="helios-remote logo" />
</p> </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 ## 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 AI Agent
│ REST API (X-Api-Key)
▼ helios-remote-cli
helios-remote-relay ──WebSocket── helios-remote-client (Windows) 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. 1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` message.
2. The **AI agent** uses `helios` to issue commands — screenshots, shell commands, window management, file transfers. 2. The **AI agent** calls the REST API to issue commands.
3. The relay server forwards everything to the correct client and streams back responses. 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 | Method | Path | Description |
remote devices # list connected devices |---|---|---|
remote screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png | `GET` | `/sessions` | List all connected clients |
remote screenshot <device> <window_label> # screenshot a specific window | `POST` | `/sessions/:id/screenshot` | Request a screenshot (returns base64 PNG) |
remote exec <device> <command...> # run shell command (PowerShell) | `POST` | `/sessions/:id/exec` | Execute a shell command |
remote exec <device> --timeout 600 <command...> # with custom timeout (seconds) | `POST` | `/sessions/:id/click` | Simulate a mouse click |
remote windows <device> # list visible windows | `POST` | `/sessions/:id/type` | Type text |
remote focus <device> <window_label> # focus a window | `POST` | `/sessions/:id/label` | Rename a session |
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
```
### 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 ### Running the Server
- **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
```bash ```bash
HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server
``` ```
Environment variables:
| Variable | Default | Description | | 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 | | `HELIOS_BIND` | `0.0.0.0:3000` | Listen address |
| `RUST_LOG` | `helios_server=debug` | Log level | | `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 | # Run a command
|---|---|---| curl -s -X POST -H "X-Api-Key: your-secret-key" \
| `helios-remote-client` | Windows | [helios-remote-client-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe) | -H "Content-Type: application/json" \
| `helios-remote-cli` | Linux | [helios-remote-cli-linux](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux) | -d '{"command": "whoami"}' \
| `helios-remote-cli` | Windows | [helios-remote-cli-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-windows.exe) | 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 ## License
MIT 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] [package]
name = "helios-remote-client" name = "helios-client"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
name = "helios-remote-client" name = "helios-client"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
@ -13,24 +13,13 @@ tokio-tungstenite = { version = "0.21", features = ["connect", "native-tls"] }
native-tls = { version = "0.2", features = [] } native-tls = { version = "0.2", features = [] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8"
chrono = "0.4"
helios-common = { path = "../common" } helios-common = { path = "../common" }
uuid = { version = "1", features = ["v4"] }
dirs = "5" dirs = "5"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22" base64 = "0.22"
png = "0.17" png = "0.17"
futures-util = "0.3" 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] [target.'cfg(windows)'.dependencies]
windows = { version = "0.54", features = [ windows = { version = "0.54", features = [
@ -39,8 +28,4 @@ windows = { version = "0.54", features = [
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_UI_WindowsAndMessaging", "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 - Connects to the relay server via WebSocket (`wss://`)
- Shell command execution (persistent PowerShell session) - Sends a `Hello` message on connect with an optional display label
- Window management (list, focus, maximize, minimize) - Handles incoming `ServerMessage` commands:
- File upload/download - `ScreenshotRequest` → captures the primary display (Windows GDI or `windows-capture`) and responds with base64 PNG
- Clipboard get/set - `ExecRequest` → runs a shell command in a persistent `cmd.exe` / PowerShell session and returns stdout/stderr/exit-code
- Program launch (fire-and-forget) - `ClickRequest` → simulates a mouse click via `SendInput` Win32 API
- User prompts (MessageBox) - `TypeRequest` → types text via `SendInput` (virtual key events)
- Single instance enforcement (PID lock file) - 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: | Crate | Purpose |
- **Relay URL** (default: `wss://remote.agent-helios.me/ws`) |---|---|
- **API Key** | `tokio` | Async runtime |
- **Device label** — must be lowercase, no whitespace, only `a-z 0-9 - _` | `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::sync::Arc;
use std::time::Duration; use std::time::Duration;
use colored::Colorize;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use native_tls::TlsConnector; use native_tls::TlsConnector;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector}; use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector};
use tracing::{error, info, warn};
use base64::Engine;
use helios_common::{ClientMessage, ServerMessage}; 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 shell;
mod screenshot; mod screenshot;
mod input;
mod windows_mgmt; mod windows_mgmt;
use display::trunc; #[derive(Debug, Serialize, Deserialize)]
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)]
struct Config { struct Config {
relay_url: String, relay_url: String,
api_key: String, relay_code: String,
label: String, label: Option<String>,
} }
impl Config { impl Config {
@ -139,148 +28,82 @@ impl Config {
let base = dirs::config_dir() let base = dirs::config_dir()
.or_else(|| dirs::home_dir()) .or_else(|| dirs::home_dir())
.unwrap_or_else(|| PathBuf::from(".")); .unwrap_or_else(|| PathBuf::from("."));
base.join("helios-remote").join("config.toml") base.join("helios-remote").join("config.json")
} }
fn load() -> Option<Self> { fn load() -> Option<Self> {
let path = Self::config_path(); let path = Self::config_path();
let data = std::fs::read_to_string(&path).ok()?; 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<()> { fn save(&self) -> std::io::Result<()> {
let path = Self::config_path(); let path = Self::config_path();
std::fs::create_dir_all(path.parent().unwrap())?; 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)?; std::fs::write(&path, data)?;
Ok(()) Ok(())
} }
} }
fn prompt_config() -> Config { fn prompt_config() -> Config {
use std::io::Write;
let relay_url = { let relay_url = {
let default = "wss://remote.agent-helios.me/ws"; println!("Relay server URL [default: wss://remote.agent-helios.me/ws]: ");
print!(" {} Relay URL [{}]: ", "".cyan().bold(), default);
std::io::stdout().flush().unwrap();
let mut input = String::new(); let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap(); std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
default.to_string() "wss://remote.agent-helios.me/ws".to_string()
} else { } else {
trimmed.to_string() trimmed.to_string()
} }
}; };
let api_key = { let relay_code = {
print!(" {} API Key: ", "".cyan().bold()); println!("Enter relay code: ");
std::io::stdout().flush().unwrap();
let mut input = String::new(); let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap(); std::io::stdin().read_line(&mut input).unwrap();
input.trim().to_string() input.trim().to_string()
}; };
let label = { let label = {
let default_label = sanitize_label(&hostname()); println!("Label for this machine (optional, press Enter to skip): ");
loop {
print!(" {} Device label [{}]: ", "".cyan().bold(), default_label);
std::io::stdout().flush().unwrap();
let mut input = String::new(); let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap(); std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim(); let trimmed = input.trim().to_string();
let candidate = if trimmed.is_empty() { if trimmed.is_empty() { None } else { Some(trimmed) }
default_label.clone()
} else {
trimmed.to_string()
}; };
if is_valid_label(&candidate) { Config { relay_url, relay_code, label }
break candidate;
}
println!(" {} Label must be lowercase, no spaces. Only a-z, 0-9, '-', '_'.",
"".red().bold());
println!(" Suggestion: {}", sanitize_label(&candidate).cyan());
}
};
Config { relay_url, api_key, label }
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
#[cfg(windows)] tracing_subscriber::fmt()
enable_ansi(); .with_env_filter(
logger::init(); std::env::var("RUST_LOG")
.unwrap_or_else(|_| "helios_client=info".to_string()),
if std::env::var("RUST_LOG").is_err() { )
unsafe { std::env::set_var("RUST_LOG", "off"); } .init();
}
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());
// Load or prompt for config // Load or prompt for config
let config = match Config::load() { let config = match Config::load() {
Some(c) => { Some(c) => {
// Validate existing label info!("Loaded config from {:?}", Config::config_path());
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 c
} }
}
None => { None => {
display::info_line("", "setup:", "No config found — first-time setup"); info!("No config found — prompting for setup");
println!();
let c = prompt_config(); let c = prompt_config();
println!();
if let Err(e) = c.save() { if let Err(e) = c.save() {
display::err("", &format!("Failed to save config: {e}")); error!("Failed to save config: {e}");
} else { } 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 c
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);
} }
}; };
let label = config.label.clone();
print_device_info(&label);
let config = Arc::new(config); let config = Arc::new(config);
let shell = Arc::new(Mutex::new(shell::PersistentShell::new())); let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
@ -289,49 +112,43 @@ async fn main() {
const MAX_BACKOFF: Duration = Duration::from_secs(30); const MAX_BACKOFF: Duration = Duration::from_secs(30);
loop { loop {
let host = config.relay_url info!("Connecting to {}", config.relay_url);
.trim_start_matches("wss://") // Build TLS connector - accepts self-signed certs for internal CA (Caddy tls internal)
.trim_start_matches("ws://")
.split('/')
.next()
.unwrap_or(&config.relay_url);
display::cmd_start("🌐", "connect", host);
let tls_connector = TlsConnector::builder() let tls_connector = TlsConnector::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.build() .build()
.expect("TLS connector build failed"); .expect("TLS connector build failed");
let connector = Connector::NativeTls(tls_connector); let connector = Connector::NativeTls(tls_connector);
match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await { match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await {
Ok((ws_stream, _)) => { Ok((ws_stream, _)) => {
display::cmd_done("🌐", "connect", host, true, "connected"); info!("Connected!");
backoff = Duration::from_secs(1); backoff = Duration::from_secs(1); // reset on success
let (mut write, mut read) = ws_stream.split(); let (mut write, mut read) = ws_stream.split();
// Send Hello with device label // Send Hello
let hello = ClientMessage::Hello { let hello = ClientMessage::Hello {
label: label.clone(), label: config.label.clone(),
}; };
let hello_json = serde_json::to_string(&hello).unwrap(); let hello_json = serde_json::to_string(&hello).unwrap();
if let Err(e) = write.send(Message::Text(hello_json)).await { 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; tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF); backoff = (backoff * 2).min(MAX_BACKOFF);
continue; continue;
} }
// Shared write half
let write = Arc::new(Mutex::new(write)); let write = Arc::new(Mutex::new(write));
// Process messages
while let Some(msg_result) = read.next().await { while let Some(msg_result) = read.next().await {
match msg_result { match msg_result {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
let server_msg: ServerMessage = match serde_json::from_str(&text) { let server_msg: ServerMessage = match serde_json::from_str(&text) {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
display::err("", &format!("Failed to parse server message: {e}")); warn!("Failed to parse server message: {e}\nRaw: {text}");
continue; continue;
} }
}; };
@ -341,16 +158,10 @@ async fn main() {
tokio::spawn(async move { tokio::spawn(async move {
let response = handle_message(server_msg, shell_clone).await; let response = handle_message(server_msg, shell_clone).await;
let json = match serde_json::to_string(&response) { let json = serde_json::to_string(&response).unwrap();
Ok(j) => j,
Err(e) => {
display::err("", &format!("Failed to serialize response: {e}"));
return;
}
};
let mut w = write_clone.lock().await; let mut w = write_clone.lock().await;
if let Err(e) = w.send(Message::Text(json)).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; let _ = w.send(Message::Pong(data)).await;
} }
Ok(Message::Close(_)) => { Ok(Message::Close(_)) => {
display::cmd_start("🌐", "connect", host); info!("Server closed connection");
display::cmd_done("🌐", "connect", host, false, "connection lost");
break; break;
} }
Err(e) => { Err(e) => {
display::cmd_done("🌐", "connect", host, false, &format!("lost: {e}")); error!("WebSocket error: {e}");
break; break;
} }
_ => {} _ => {}
} }
} }
warn!("Disconnected. Reconnecting in {:?}...", backoff);
} }
Err(e) => { Err(e) => {
display::cmd_start("🌐", "connect", host); error!("Connection failed: {e}");
display::cmd_done("🌐", "connect", host, false, &format!("{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( async fn handle_message(
msg: ServerMessage, msg: ServerMessage,
shell: Arc<Mutex<shell::PersistentShell>>, shell: Arc<Mutex<shell::PersistentShell>>,
) -> ClientMessage { ) -> ClientMessage {
match msg { 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 } => { ServerMessage::ScreenshotRequest { request_id } => {
display::cmd_start("📷", "screenshot", "screen");
match screenshot::take_screenshot() { match screenshot::take_screenshot() {
Ok((image_base64, width, height)) => { Ok((image_base64, width, height)) => ClientMessage::ScreenshotResponse {
display::cmd_done("📷", "screenshot", "screen", true, &format!("{width}×{height}")); request_id,
ClientMessage::ScreenshotResponse { request_id, image_base64, width, height } image_base64,
} width,
height,
},
Err(e) => { Err(e) => {
display::cmd_done("📷", "screenshot", "screen", false, &format!("{e}")); error!("Screenshot failed: {e}");
ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") } ClientMessage::Error {
request_id,
message: format!("Screenshot failed: {e}"),
}
} }
} }
} }
ServerMessage::InformRequest { request_id, message, title } => { ServerMessage::ExecRequest { request_id, command } => {
let msg = message.clone(); info!("Exec: {command}");
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);
let mut sh = shell.lock().await; let mut sh = shell.lock().await;
match sh.run(&command, timeout_ms).await { match sh.run(&command).await {
Ok((stdout, stderr, exit_code)) => { Ok((stdout, stderr, exit_code)) => ClientMessage::ExecResponse {
let result = if exit_code != 0 { request_id,
let err_line = stderr.lines() stdout,
.map(|l| l.trim()) stderr,
.find(|l| !l.is_empty() exit_code,
&& !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 }
}
Err(e) => { Err(e) => {
display::cmd_done("", "execute", &payload, false, &format!("exec failed: {e}")); error!("Exec failed for command {:?}: {e}", command);
ClientMessage::Error { request_id, message: format!("Exec failed for command {:?}.\nError: {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 } => { ServerMessage::ListWindowsRequest { request_id } => {
display::cmd_start("🪟", "list windows", ""); info!("ListWindows");
match windows_mgmt::list_windows() { match windows_mgmt::list_windows() {
Ok(windows) => { Ok(windows) => ClientMessage::ListWindowsResponse { request_id, windows },
display::cmd_done("🪟", "list windows", "", true, &format!("{} windows", windows.len()));
ClientMessage::ListWindowsResponse { request_id, windows }
}
Err(e) => { Err(e) => {
display::cmd_done("🪟", "list windows", "", false, &e); error!("ListWindows failed: {e}");
ClientMessage::Error { request_id, message: e } ClientMessage::Error { request_id, message: e }
} }
} }
} }
ServerMessage::MinimizeAllRequest { request_id } => { ServerMessage::MinimizeAllRequest { request_id } => {
display::cmd_start("🪟", "minimize all", ""); info!("MinimizeAll");
match windows_mgmt::minimize_all() { match windows_mgmt::minimize_all() {
Ok(()) => { Ok(()) => ClientMessage::Ack { request_id },
display::cmd_done("🪟", "minimize all", "", true, "done");
ClientMessage::Ack { request_id }
}
Err(e) => { Err(e) => {
display::cmd_done("🪟", "minimize all", "", false, &e); error!("MinimizeAll failed: {e}");
ClientMessage::Error { request_id, message: e } ClientMessage::Error { request_id, message: e }
} }
} }
} }
ServerMessage::FocusWindowRequest { request_id, window_id } => { ServerMessage::FocusWindowRequest { request_id, window_id } => {
let payload = format!("{window_id}"); info!("FocusWindow: {window_id}");
display::cmd_start("🪟", "focus window", &payload);
match windows_mgmt::focus_window(window_id) { match windows_mgmt::focus_window(window_id) {
Ok(()) => { Ok(()) => ClientMessage::Ack { request_id },
display::cmd_done("🪟", "focus window", &payload, true, "done");
ClientMessage::Ack { request_id }
}
Err(e) => { Err(e) => {
display::cmd_done("🪟", "focus window", &payload, false, &e); error!("FocusWindow failed: {e}");
ClientMessage::Error { request_id, message: e } ClientMessage::Error { request_id, message: e }
} }
} }
} }
ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => { ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => {
let payload = format!("{window_id}"); info!("MaximizeAndFocus: {window_id}");
display::cmd_start("🪟", "maximize", &payload);
match windows_mgmt::maximize_and_focus(window_id) { match windows_mgmt::maximize_and_focus(window_id) {
Ok(()) => { Ok(()) => ClientMessage::Ack { request_id },
display::cmd_done("🪟", "maximize", &payload, true, "done");
ClientMessage::Ack { request_id }
}
Err(e) => { Err(e) => {
display::cmd_done("🪟", "maximize", &payload, false, &e); error!("MaximizeAndFocus failed: {e}");
ClientMessage::Error { request_id, message: 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 } => { ServerMessage::Ack { request_id } => {
info!("Server ack for {request_id}");
// Nothing to do - server acked something we sent
ClientMessage::Ack { request_id } ClientMessage::Ack { request_id }
} }
ServerMessage::Error { request_id, message } => { 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 { if let Some(rid) = request_id {
ClientMessage::Ack { request_id: rid } ClientMessage::Ack { request_id: rid }
} else { } else {
ClientMessage::Hello { label: String::new() } ClientMessage::Hello { label: None }
} }
} }
} }

View file

@ -3,10 +3,10 @@ use base64::Engine;
#[cfg(windows)] #[cfg(windows)]
pub fn take_screenshot() -> Result<(String, u32, u32), String> { pub fn take_screenshot() -> Result<(String, u32, u32), String> {
use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Gdi::{ use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
GetDIBits, SelectObject, BITMAPINFO, BITMAPINFOHEADER, GetDIBits, GetObjectW, SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
DIB_RGB_COLORS, SRCCOPY, DIB_RGB_COLORS, SRCCOPY,
}; };
use windows::Win32::UI::WindowsAndMessaging::GetDesktopWindow; 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))] #[cfg(not(windows))]
pub fn take_screenshot() -> Result<(String, u32, u32), String> { pub fn take_screenshot() -> Result<(String, u32, u32), String> {
// Stub for non-Windows builds // Stub for non-Windows builds

View file

@ -1,51 +1,161 @@
/// Shell execution — each command runs in its own fresh process. /// Persistent shell session that keeps a cmd.exe (Windows) or sh (Unix) alive
/// On Windows we use powershell.exe -NoProfile so the user's $PROFILE /// between commands, so state like `cd` is preserved.
/// (which might run `clear`) is never loaded. use std::process::Stdio;
use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command; 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 { 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> { async fn spawn(&mut self) -> Result<(), String> {
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
let timeout = Duration::from_millis(timeout_ms);
#[cfg(windows)] #[cfg(windows)]
{ let (program, args) = ("cmd.exe", vec!["/Q"]);
let mut cmd = Command::new("powershell.exe");
cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]);
run_captured(cmd, timeout).await
}
#[cfg(not(windows))] #[cfg(not(windows))]
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_inner(&mut self, command: &str) -> Result<(String, String, i32), String> {
let shell = self.child.as_mut().ok_or("no shell")?;
// 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");
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 cmd = Command::new("sh"); let mut reader = shell.stdout_lines.lock().await;
cmd.args(["-c", command]); loop {
run_captured(cmd, timeout).await 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) {
async fn run_captured( // Parse exit code from sentinel line
mut cmd: Command, let code_str = line.trim_end().trim_start_matches(SENTINEL);
timeout: Duration, exit_code = code_str.trim().parse().unwrap_or(0);
) -> Result<(String, String, i32), String> { break;
cmd.stdout(std::process::Stdio::piped()) } else {
.stderr(std::process::Stdio::piped()); stdout_buf.push_str(&line);
}
let child = cmd.spawn() }
.map_err(|e| format!("Failed to spawn process: {e}"))?; Ok(Err(e)) => {
return Err(format!("Shell stdout read error: {e}"));
match tokio::time::timeout(timeout, child.wait_with_output()).await { }
Ok(Ok(out)) => Ok(( Err(_) => {
String::from_utf8_lossy(&out.stdout).into_owned(), return Err(format!(
String::from_utf8_lossy(&out.stderr).into_owned(), "Shell stdout timed out after {}ms waiting for command to finish.\nCommand: {command}\nOutput so far: {stdout_buf}",
out.status.code().unwrap_or(-1), OUTPUT_TIMEOUT_MS
)), ));
Ok(Err(e)) => Err(format!("Process wait failed: {e}")), }
Err(_) => Err(format!("Command timed out after {}ms", timeout.as_millis())), }
}
}
// 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 helios_common::protocol::WindowInfo;
use std::collections::HashMap;
// ── Windows implementation ────────────────────────────────────────────────── // ── Windows implementation ──────────────────────────────────────────────────
#[cfg(windows)] #[cfg(windows)]
mod win_impl { mod win_impl {
use super::*; use super::*;
use std::sync::Mutex;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED};
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
BringWindowToTop, EnumWindows, GetWindowTextW, BringWindowToTop, EnumWindows, GetWindowTextW, IsWindowVisible, SetForegroundWindow,
IsWindowVisible, SetForegroundWindow, ShowWindow, ShowWindow, SW_MAXIMIZE, SW_MINIMIZE, SHOW_WINDOW_CMD,
SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE,
}; };
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 { unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let list = &mut *(lparam.0 as *mut Vec<HWND>); let list = &mut *(lparam.0 as *mut Vec<HWND>);
list.push(hwnd); list.push(hwnd);
@ -39,123 +30,25 @@ mod win_impl {
list 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 { fn hwnd_title(hwnd: HWND) -> String {
let mut buf = [0u16; 512]; let mut buf = [0u16; 512];
let len = unsafe { GetWindowTextW(hwnd, &mut buf) }; let len = unsafe { GetWindowTextW(hwnd, &mut buf) };
String::from_utf16_lossy(&buf[..len as usize]) 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> { pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
let hwnds = get_all_hwnds(); let hwnds = get_all_hwnds();
let mut windows = Vec::new();
// Collect visible windows with non-empty titles for hwnd in hwnds {
let mut raw_windows: Vec<(HWND, String, String)> = Vec::new(); let visible = unsafe { IsWindowVisible(hwnd).as_bool() };
for hwnd in &hwnds { let title = hwnd_title(hwnd);
let visible = unsafe { IsWindowVisible(*hwnd).as_bool() };
if !visible {
continue;
}
if is_cloaked(*hwnd) {
continue;
}
let title = hwnd_title(*hwnd);
if title.is_empty() { if title.is_empty() {
continue; 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 { windows.push(WindowInfo {
id: hwnd.0 as u64, id: hwnd.0 as u64,
title, title,
label, visible,
visible: true,
}); });
} }
Ok(windows) Ok(windows)
@ -175,17 +68,12 @@ mod win_impl {
Ok(()) 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> { pub fn focus_window(window_id: u64) -> Result<(), String> {
let hwnd = HWND(window_id as isize); let hwnd = HWND(window_id as isize);
unsafe { force_foreground(hwnd); } unsafe {
BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?;
SetForegroundWindow(hwnd);
}
Ok(()) Ok(())
} }
@ -193,7 +81,8 @@ mod win_impl {
let hwnd = HWND(window_id as isize); let hwnd = HWND(window_id as isize);
unsafe { unsafe {
ShowWindow(hwnd, SW_MAXIMIZE); ShowWindow(hwnd, SW_MAXIMIZE);
force_foreground(hwnd); BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?;
SetForegroundWindow(hwnd);
} }
Ok(()) Ok(())
} }

View file

@ -1,74 +1,36 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
/// Information about a single window on the client machine. /// Information about a single window on the client machine
/// `label` is a human-readable, lowercase identifier (e.g. "google_chrome", "discord").
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo { pub struct WindowInfo {
pub id: u64, pub id: u64,
pub title: String, pub title: String,
pub label: String,
pub visible: bool, 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 /// Messages sent from the relay server to a connected client
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage { pub enum ServerMessage {
/// Request a full-screen screenshot /// Request a screenshot from the client
ScreenshotRequest { request_id: Uuid }, 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 /// Execute a shell command on the client
ExecRequest { ExecRequest {
request_id: Uuid, request_id: Uuid,
command: String, 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 /// Acknowledge a client message
Ack { request_id: Uuid }, Ack { request_id: Uuid },
@ -85,39 +47,14 @@ pub enum ServerMessage {
FocusWindowRequest { request_id: Uuid, window_id: u64 }, FocusWindowRequest { request_id: Uuid, window_id: u64 },
/// Maximize a window and bring it to the foreground /// Maximize a window and bring it to the foreground
MaximizeAndFocusRequest { request_id: Uuid, window_id: u64 }, 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 /// Messages sent from the client to the relay server
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage { pub enum ClientMessage {
/// Client registers itself with its device label /// Client registers itself with optional display name
Hello { label: String }, Hello { label: Option<String> },
/// Response to a screenshot request — base64-encoded PNG /// Response to a screenshot request — base64-encoded PNG
ScreenshotResponse { ScreenshotResponse {
request_id: Uuid, request_id: Uuid,
@ -132,7 +69,7 @@ pub enum ClientMessage {
stderr: String, stderr: String,
exit_code: i32, exit_code: i32,
}, },
/// Generic acknowledgement /// Generic acknowledgement for click/type/minimize-all/focus/maximize
Ack { request_id: Uuid }, Ack { request_id: Uuid },
/// Client error response /// Client error response
Error { Error {
@ -144,64 +81,32 @@ pub enum ClientMessage {
request_id: Uuid, request_id: Uuid,
windows: Vec<WindowInfo>, windows: Vec<WindowInfo>,
}, },
/// Response to a version request }
VersionResponse {
request_id: Uuid, /// Mouse button variants
version: String, #[derive(Debug, Clone, Serialize, Deserialize)]
commit: String, #[serde(rename_all = "lowercase")]
}, pub enum MouseButton {
LogsResponse { Left,
request_id: Uuid, Right,
content: String, Middle,
log_path: String, }
},
/// Response to a download request impl Default for MouseButton {
DownloadResponse { fn default() -> Self {
request_id: Uuid, MouseButton::Left
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,
},
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_server_message_serialization() { fn test_server_message_serialization() {
let msg = ServerMessage::ExecRequest { let msg = ServerMessage::ExecRequest {
request_id: Uuid::nil(), request_id: Uuid::nil(),
command: "echo hello".into(), command: "echo hello".into(),
timeout_ms: None,
}; };
let json = serde_json::to_string(&msg).unwrap(); let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("exec_request")); assert!(json.contains("exec_request"));
@ -210,9 +115,25 @@ mod tests {
#[test] #[test]
fn test_client_message_serialization() { 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(); let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("hello")); assert!(json.contains("hello"));
assert!(json.contains("test-pc")); 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] [package]
name = "helios-remote-relay" name = "helios-server"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
name = "helios-remote-relay" name = "helios-server"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
@ -22,4 +22,3 @@ tokio-tungstenite = "0.21"
futures-util = "0.3" futures-util = "0.3"
dashmap = "5" dashmap = "5"
anyhow = "1" 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 std::time::Duration;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
Json, Json,
@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use tracing::error; use tracing::error;
use helios_common::protocol::{ClientMessage, ServerMessage}; use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage};
use crate::AppState; use crate::AppState;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@ -21,29 +21,33 @@ pub struct ErrorBody {
pub error: String, pub error: String,
} }
fn not_found(label: &str) -> (StatusCode, Json<ErrorBody>) { fn not_found(session_id: &str) -> (StatusCode, Json<ErrorBody>) {
( (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(ErrorBody { 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, StatusCode::GATEWAY_TIMEOUT,
Json(ErrorBody { 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, StatusCode::BAD_GATEWAY,
Json(ErrorBody { 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>( async fn dispatch<F>(
state: &AppState, state: &AppState,
label: &str, session_id: &str,
op: &str, op: &str,
make_msg: F, make_msg: F,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)> ) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where where
F: FnOnce(Uuid) -> ServerMessage, 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 let tx = state
.sessions .sessions
.get_cmd_tx(label) .get_cmd_tx(&id)
.ok_or_else(|| not_found(label))?; .ok_or_else(|| not_found(session_id))?;
let request_id = Uuid::new_v4(); let request_id = Uuid::new_v4();
let rx = state.sessions.register_pending(request_id); let rx = state.sessions.register_pending(request_id);
let msg = make_msg(request_id); let msg = make_msg(request_id);
tx.send(msg).await.map_err(|e| { tx.send(msg).await.map_err(|e| {
error!("Channel send failed for device={label}, op={op}: {e}"); error!("Channel send failed for session={session_id}, op={op}: {e}");
send_error(label, op) 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(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(send_error(label, op)), Ok(Err(_)) => Err(send_error(session_id, op)),
Err(_) => Err(timeout_error(label, op)), Err(_) => Err(timeout_error(session_id, op)),
} }
} }
// ── Handlers ───────────────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────────
/// GET /devices — list all connected clients /// GET /sessions — list all connected clients
pub async fn list_devices(State(state): State<AppState>) -> Json<serde_json::Value> { pub async fn list_sessions(State(state): State<AppState>) -> Json<serde_json::Value> {
let devices = state.sessions.list(); let sessions = state.sessions.list();
Json(serde_json::json!({ "devices": devices })) Json(serde_json::json!({ "sessions": sessions }))
} }
/// POST /devices/:label/screenshot — full screen screenshot /// POST /sessions/:id/screenshot
pub async fn request_screenshot( pub async fn request_screenshot(
Path(label): Path<String>, Path(session_id): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &label, "screenshot", |rid| { match dispatch(&state, &session_id, "screenshot", |rid| {
ServerMessage::ScreenshotRequest { request_id: rid } ServerMessage::ScreenshotRequest { request_id: rid }
}).await { })
Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => ( .await
{
Ok(ClientMessage::ScreenshotResponse {
image_base64,
width,
height,
..
}) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "image_base64": image_base64, "width": width, "height": height })), Json(serde_json::json!({
).into_response(), "image_base64": image_base64,
"width": width,
"height": height,
})),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), 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(), Err(e) => e.into_response(),
} }
} }
/// POST /devices/:label/windows/:window_id/screenshot /// POST /sessions/:id/exec
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
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ExecBody { pub struct ExecBody {
pub command: String, pub command: String,
pub timeout_ms: Option<u64>,
} }
pub async fn request_exec( pub async fn request_exec(
Path(label): Path<String>, Path(session_id): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<ExecBody>, Json(body): Json<ExecBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let server_timeout = body.timeout_ms match dispatch(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
.map(|ms| Duration::from_millis(ms + 5_000))
.unwrap_or(REQUEST_TIMEOUT);
match dispatch_with_timeout(&state, &label, "exec", |rid| ServerMessage::ExecRequest {
request_id: rid, request_id: rid,
command: body.command.clone(), command: body.command.clone(),
timeout_ms: body.timeout_ms, })
}, server_timeout).await { .await
Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => ( {
Ok(ClientMessage::ExecResponse {
stdout,
stderr,
exit_code,
..
}) => (
StatusCode::OK, StatusCode::OK,
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(),
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!({ Json(serde_json::json!({
"commit": env!("GIT_COMMIT"), "stdout": stdout,
})) "stderr": stderr,
} "exit_code": exit_code,
})),
/// GET /devices/:label/version — client version )
pub async fn client_version( .into_response(),
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, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), 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(), Err(e) => e.into_response(),
} }
} }
/// POST /devices/:label/upload /// POST /sessions/:id/click
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UploadBody { pub struct ClickBody {
pub path: String, pub x: i32,
pub content_base64: String, pub y: i32,
}
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,
#[serde(default)] #[serde(default)]
pub args: Vec<String>, pub button: MouseButton,
} }
pub async fn run_program( pub async fn request_click(
Path(label): Path<String>, Path(session_id): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<RunBody>, Json(body): Json<ClickBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &label, "run", |rid| ServerMessage::RunRequest { match dispatch(&state, &session_id, "click", |rid| ServerMessage::ClickRequest {
request_id: rid, request_id: rid,
program: body.program.clone(), x: body.x,
args: body.args.clone(), y: body.y,
}).await { button: body.button.clone(),
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// GET /devices/:label/clipboard /// POST /sessions/:id/type
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
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ClipboardSetBody { pub struct TypeBody {
pub text: String, pub text: String,
} }
pub async fn clipboard_set( pub async fn request_type(
Path(label): Path<String>, Path(session_id): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<ClipboardSetBody>, Json(body): Json<TypeBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &label, "clipboard_set", |rid| { match dispatch(&state, &session_id, "type", |rid| ServerMessage::TypeRequest {
ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() } request_id: rid,
}).await { text: body.text.clone(),
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /relay/update — self-update the relay binary and restart the service /// GET /sessions/:id/windows
pub async fn relay_update() -> impl IntoResponse { pub async fn list_windows(
tokio::spawn(async { Path(session_id): Path<String>,
// 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>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch_with_timeout(&state, &label, "update", |rid| { match dispatch(&state, &session_id, "list_windows", |rid| {
ServerMessage::UpdateRequest { request_id: rid } ServerMessage::ListWindowsRequest { request_id: rid }
}, Duration::from_secs(60)).await { })
Ok(ClientMessage::UpdateResponse { success, message, .. }) => ( .await
{
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "success": success, "message": message })), Json(serde_json::json!({ "windows": windows })),
).into_response(), )
Ok(ClientMessage::Ack { .. }) => ( .into_response(),
StatusCode::OK,
Json(serde_json::json!({ "success": true, "message": "update acknowledged" })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "success": false, "message": message })), Json(serde_json::json!({ "error": message })),
).into_response(), )
.into_response(),
Ok(_) => ( Ok(_) => (
StatusCode::OK, StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "success": true, "message": "acknowledged" })), Json(serde_json::json!({ "error": "Unexpected response from client" })),
).into_response(), )
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /devices/:label/inform /// POST /sessions/:id/windows/minimize-all
pub async fn inform_user( pub async fn minimize_all(
Path(label): Path<String>, Path(session_id): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<PromptBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &label, "inform", |rid| ServerMessage::InformRequest { match dispatch(&state, &session_id, "minimize_all", |rid| {
request_id: rid, ServerMessage::MinimizeAllRequest { request_id: rid }
message: body.message.clone(), })
title: body.title.clone(), .await
}).await { {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.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)] #[derive(Deserialize)]
pub struct PromptBody { pub struct LabelBody {
pub message: String, pub label: String,
pub title: Option<String>,
} }
pub async fn prompt_user( pub async fn set_label(
Path(label): Path<String>, Path(session_id): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<PromptBody>, Json(body): Json<LabelBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &label, "prompt", |rid| ServerMessage::PromptRequest { let id = match session_id.parse::<Uuid>() {
request_id: rid, Ok(id) => id,
message: body.message.clone(), Err(_) => {
title: body.title.clone(), return (
}).await { StatusCode::BAD_REQUEST,
Ok(ClientMessage::PromptResponse { answer, .. }) => { Json(serde_json::json!({ "error": format!("Invalid session id: '{session_id}'") })),
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "answer": answer }))).into_response() )
.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()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
const GIT_COMMIT: &str = env!("GIT_COMMIT");
info!("helios-server ({GIT_COMMIT})");
let api_key = std::env::var("HELIOS_API_KEY") let api_key = std::env::var("HELIOS_API_KEY")
.unwrap_or_else(|_| "dev-secret".to_string()); .unwrap_or_else(|_| "dev-secret".to_string());
@ -45,30 +42,20 @@ async fn main() -> anyhow::Result<()> {
}; };
let protected = Router::new() let protected = Router::new()
.route("/devices", get(api::list_devices)) .route("/sessions", get(api::list_sessions))
.route("/devices/:label/screenshot", post(api::request_screenshot)) .route("/sessions/:id/screenshot", post(api::request_screenshot))
.route("/devices/:label/exec", post(api::request_exec)) .route("/sessions/:id/exec", post(api::request_exec))
.route("/devices/:label/prompt", post(api::prompt_user)) .route("/sessions/:id/click", post(api::request_click))
.route("/devices/:label/inform", post(api::inform_user)) .route("/sessions/:id/type", post(api::request_type))
.route("/devices/:label/windows", get(api::list_windows)) .route("/sessions/:id/label", post(api::set_label))
.route("/devices/:label/windows/minimize-all", post(api::minimize_all)) .route("/sessions/:id/windows", get(api::list_windows))
.route("/devices/:label/logs", get(api::logs)) .route("/sessions/:id/windows/minimize-all", post(api::minimize_all))
.route("/devices/:label/windows/:window_id/screenshot", post(api::window_screenshot)) .route("/sessions/:id/windows/:window_id/focus", post(api::focus_window))
.route("/devices/:label/windows/:window_id/focus", post(api::focus_window)) .route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus))
.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))
.layer(middleware::from_fn_with_state(state.clone(), require_api_key)); .layer(middleware::from_fn_with_state(state.clone(), require_api_key));
let app = Router::new() let app = Router::new()
.route("/ws", get(ws_handler::ws_upgrade)) .route("/ws", get(ws_handler::ws_upgrade))
.route("/version", get(api::server_version))
.merge(protected) .merge(protected)
.with_state(state); .with_state(state);

View file

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

View file

@ -5,6 +5,7 @@ use axum::{
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use uuid::Uuid;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use helios_common::protocol::{ClientMessage, ServerMessage}; use helios_common::protocol::{ClientMessage, ServerMessage};
@ -18,57 +19,32 @@ pub async fn ws_upgrade(
} }
async fn handle_socket(socket: WebSocket, state: AppState) { 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 (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 // Register session (label filled in on Hello)
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
let session = Session { let session = Session {
label: label.clone(), id: session_id,
label: None,
cmd_tx, cmd_tx,
}; };
state.sessions.insert(session); 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 // Spawn task: forward server commands → WS
let label_clone = label.clone();
let send_task = tokio::spawn(async move { let send_task = tokio::spawn(async move {
while let Some(msg) = cmd_rx.recv().await { while let Some(msg) = cmd_rx.recv().await {
match serde_json::to_string(&msg) { match serde_json::to_string(&msg) {
Ok(json) => { Ok(json) => {
if let Err(e) = ws_tx.send(Message::Text(json.into())).await { 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; break;
} }
} }
Err(e) => { 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 { match result {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&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) => { Err(e) => {
warn!("Invalid JSON from device={label}: {e}"); warn!("Invalid JSON from session={session_id}: {e} | raw={text}");
} }
} }
} }
Ok(Message::Close(_)) => { Ok(Message::Close(_)) => {
info!("Client disconnected gracefully: device={label}"); info!("Client disconnected gracefully: session={session_id}");
break; break;
} }
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {} Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {}
Err(e) => { Err(e) => {
error!("WS receive error for device={label}: {e}"); error!("WS receive error for session={session_id}: {e}");
break; break;
} }
} }
} }
send_task.abort(); send_task.abort();
state.sessions.remove(&label); state.sessions.remove(&session_id);
info!("Session cleaned up: device={label}"); 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 { match &msg {
ClientMessage::Hello { .. } => { ClientMessage::Hello { label } => {
debug!("Duplicate Hello from device={label}, ignoring"); 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::ScreenshotResponse { request_id, .. }
| ClientMessage::ExecResponse { request_id, .. } | ClientMessage::ExecResponse { request_id, .. }
| ClientMessage::ListWindowsResponse { 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::Ack { request_id }
| ClientMessage::Error { request_id, .. } => { | ClientMessage::Error { request_id, .. } => {
let rid = *request_id; let rid = *request_id;
if !state.sessions.resolve_pending(rid, msg) { 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 # 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. # IMPORTANT: Windows Defender may block the executable.
# If the download or launch fails, disable real-time protection first (run as Admin): # If the download or launch fails, disable real-time protection first (run as Admin):
@ -9,17 +9,11 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$url = "https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe" $url = "https://github.com/agent-helios/helios-remote/releases/latest/download/helios-remote-client-windows.exe"
$dest = "$env:USERPROFILE\Desktop\Helios Remote.exe" $dest = "$env:TEMP\helios-remote.exe"
Write-Host "Downloading helios-remote client..." Write-Host "Downloading helios-remote client..."
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
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)
}
Write-Host "Starting..." Write-Host "Starting..."
Start-Process -FilePath $dest Start-Process -FilePath $dest -NoNewWindow