diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..3b25b33
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,6 @@
+[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"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f88b336..bdcd514 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,8 +37,8 @@ jobs:
with:
targets: x86_64-pc-windows-gnu
- - name: Install MinGW cross-compiler
- run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64
+ - name: Install MinGW cross-compiler and tools
+ run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
@@ -47,7 +47,7 @@ jobs:
- name: Build Windows client (cross-compile)
run: |
- cargo build --release --package helios-client --target x86_64-pc-windows-gnu
+ cargo build --release --package helios-remote-client --target x86_64-pc-windows-gnu
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
@@ -55,12 +55,12 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: helios-remote-client-windows
- path: target/x86_64-pc-windows-gnu/release/helios-client.exe
+ path: target/x86_64-pc-windows-gnu/release/helios-remote-client.exe
if-no-files-found: error
- name: Rename exe for release
if: github.ref == 'refs/heads/master'
- run: cp target/x86_64-pc-windows-gnu/release/helios-client.exe helios-remote-client-windows.exe
+ run: cp target/x86_64-pc-windows-gnu/release/helios-remote-client.exe helios-remote-client-windows.exe
- name: Publish rolling release (latest)
if: github.ref == 'refs/heads/master'
@@ -73,3 +73,126 @@ jobs:
files: helios-remote-client-windows.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Deploy client to VPS
+ if: github.ref == 'refs/heads/master'
+ env:
+ VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
+ run: |
+ mkdir -p ~/.ssh
+ echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+ ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts
+ scp -i ~/.ssh/deploy_key helios-remote-client-windows.exe \
+ root@46.225.185.232:/var/www/helios-remote/helios-remote-client-windows.exe
+
+ deploy-relay:
+ runs-on: ubuntu-latest
+ needs: build-and-test
+ if: github.ref == 'refs/heads/master'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust + x86_64-linux target
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-unknown-linux-gnu
+
+ - name: Install cross-linker
+ run: sudo apt-get update && sudo apt-get install -y gcc-x86-64-linux-gnu
+
+ - name: Cache dependencies
+ uses: Swatinem/rust-cache@v2
+ with:
+ key: linux-x86_64
+
+ - name: Build relay (x86_64 Linux)
+ run: cargo build --release --package helios-remote-relay --target x86_64-unknown-linux-gnu
+ env:
+ CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc
+
+ - name: Deploy relay to VPS
+ env:
+ VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
+ run: |
+ mkdir -p ~/.ssh
+ echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+ ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts
+ # Only publish to download URL — relay updates itself when triggered by CLI
+ scp -i ~/.ssh/deploy_key \
+ target/x86_64-unknown-linux-gnu/release/helios-remote-relay \
+ root@46.225.185.232:/var/www/helios-remote/helios-remote-relay-linux
+ # Write version.json so CLI knows what's available
+ echo "{\"commit\":\"$(git rev-parse --short HEAD)\"}" > version.json
+ scp -i ~/.ssh/deploy_key version.json \
+ root@46.225.185.232:/var/www/helios-remote/version.json
+
+ build-cli:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust (stable) + targets
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: x86_64-unknown-linux-gnu,x86_64-pc-windows-gnu,aarch64-unknown-linux-gnu
+
+ - name: Install cross-compilers
+ run: sudo apt-get update && sudo apt-get install -y gcc-x86-64-linux-gnu gcc-mingw-w64-x86-64 mingw-w64-tools gcc-aarch64-linux-gnu
+
+ - name: Cache dependencies
+ uses: Swatinem/rust-cache@v2
+ with:
+ key: cli
+
+ - name: Build CLI (Linux x86_64)
+ run: cargo build --release --package helios-remote-cli --target x86_64-unknown-linux-gnu
+ env:
+ CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc
+
+ - name: Build CLI (Linux aarch64)
+ run: cargo build --release --package helios-remote-cli --target aarch64-unknown-linux-gnu
+ env:
+ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
+
+ - name: Build CLI (Windows x86_64)
+ run: cargo build --release --package helios-remote-cli --target x86_64-pc-windows-gnu
+ env:
+ CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
+
+ - name: Upload Linux CLI artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: helios-remote-cli-linux
+ path: target/x86_64-unknown-linux-gnu/release/helios-remote-cli
+ if-no-files-found: error
+
+ - name: Upload Windows CLI artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: helios-remote-cli-windows
+ path: target/x86_64-pc-windows-gnu/release/helios-remote-cli.exe
+ if-no-files-found: error
+
+ - name: Deploy CLI to VPS
+ if: github.ref == 'refs/heads/master'
+ env:
+ VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
+ run: |
+ mkdir -p ~/.ssh
+ echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+ ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts
+ scp -i ~/.ssh/deploy_key \
+ target/x86_64-unknown-linux-gnu/release/helios-remote-cli \
+ root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-linux
+ scp -i ~/.ssh/deploy_key \
+ target/aarch64-unknown-linux-gnu/release/helios-remote-cli \
+ root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-linux-aarch64
+ scp -i ~/.ssh/deploy_key \
+ target/x86_64-pc-windows-gnu/release/helios-remote-cli.exe \
+ root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-windows.exe
diff --git a/.gitignore b/.gitignore
index 6e65eaf..5b6f0e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ Cargo.lock
**/*.rs.bk
.env
*.pdb
+remote
diff --git a/Cargo.toml b/Cargo.toml
index 40cb1dd..816c174 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,5 +3,6 @@ members = [
"crates/common",
"crates/server",
"crates/client",
+ "crates/cli",
]
resolver = "2"
diff --git a/README.md b/README.md
index a1e0f03..c0f4c22 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-**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.
+**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.
## Quick Connect
@@ -26,109 +26,91 @@ irm https://raw.githubusercontent.com/agent-helios/helios-remote/master/scripts/
---
-## 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
+## How It Works
```
AI Agent
- │ REST API (X-Api-Key)
- ▼
-helios-server ──WebSocket── helios-client (Windows)
- │ │
-POST /sessions/:id/screenshot │ Captures screen → base64 PNG
-POST /sessions/:id/exec │ Runs command in persistent shell
-POST /sessions/:id/click │ Simulates mouse click
-POST /sessions/:id/type │ Types text
+ │
+ ▼ helios-remote-cli
+helios-remote-relay ──WebSocket── helios-remote-client (Windows)
```
-1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` message.
-2. The **AI agent** calls the REST API to issue commands.
-3. The relay server forwards commands to the correct client session and streams back responses.
+1. The **Windows client** connects to the relay server via WebSocket and registers with its device label.
+2. The **AI agent** uses `helios` to issue commands — screenshots, shell commands, window management, file transfers.
+3. The relay server forwards everything to the correct client and streams back responses.
-## Server
+Device labels are the sole identifier. Only one client instance can run per device.
-### REST API
+---
-All endpoints require the `X-Api-Key` header.
+## remote CLI
-| Method | Path | Description |
-|---|---|---|
-| `GET` | `/sessions` | List all connected clients |
-| `POST` | `/sessions/:id/screenshot` | Request a screenshot (returns base64 PNG) |
-| `POST` | `/sessions/:id/exec` | Execute a shell command |
-| `POST` | `/sessions/:id/click` | Simulate a mouse click |
-| `POST` | `/sessions/:id/type` | Type text |
-| `POST` | `/sessions/:id/label` | Rename a session |
+```bash
+remote devices # list connected devices
+remote screenshot screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
+remote screenshot # screenshot a specific window
+remote exec # run shell command (PowerShell)
+remote exec --timeout 600 # with custom timeout (seconds)
+remote windows # list visible windows
+remote focus # focus a window
+remote maximize # maximize and focus a window
+remote minimize-all # minimize all windows
+remote inform "Something happened" # notify user (fire-and-forget, no response)
+remote inform "message" --title "Title" # with custom dialog title
+remote run [args...] # launch program (fire-and-forget)
+remote clipboard-get # get clipboard text
+remote clipboard-set # set clipboard text
+remote upload # upload file to device
+remote download # download file from device
+remote version # compare latest/relay/cli/client commits
+remote update # update all components to latest version
+remote logs # fetch last 20 lines of client log (default)
+remote logs --lines 200 # custom line count
+```
-### WebSocket
+### Update System
-Clients connect to `ws://host:3000/ws`. No auth required at the transport layer — the server trusts all WS connections as client agents.
+`remote update ` checks `version.json` on the download server for the latest available commit and updates any component that's behind:
-### Running the Server
+- **Relay** — downloads new binary, replaces itself, restarts via systemd
+- **Client** — downloads new binary, replaces itself, relaunches automatically
+- **CLI** — downloads new binary, replaces itself, re-executes the update command
+
+CI publishes new binaries after every push to `master` but does **not** auto-restart the relay. Updates only happen when explicitly triggered via `remote update`.
+
+---
+
+## Server Setup
```bash
HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server
```
-Environment variables:
-
| Variable | Default | Description |
|---|---|---|
-| `HELIOS_API_KEY` | `dev-secret` | API key for REST endpoints |
+| `HELIOS_API_KEY` | `dev-secret` | API key |
| `HELIOS_BIND` | `0.0.0.0:3000` | Listen address |
| `RUST_LOG` | `helios_server=debug` | Log level |
-### Example API Usage
+---
-```bash
-# List sessions
-curl -H "X-Api-Key: your-secret-key" http://localhost:3000/sessions
+## Downloads
-# Take a screenshot
-curl -s -X POST -H "X-Api-Key: your-secret-key" \
- http://localhost:3000/sessions//screenshot
+Pre-built binaries are available at:
-# Run a command
-curl -s -X POST -H "X-Api-Key: your-secret-key" \
- -H "Content-Type: application/json" \
- -d '{"command": "whoami"}' \
- http://localhost:3000/sessions//exec
+| Binary | Platform | Link |
+|---|---|---|
+| `helios-remote-client` | Windows | [helios-remote-client-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe) |
+| `helios-remote-cli` | Linux | [helios-remote-cli-linux](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux) |
+| `helios-remote-cli` | Windows | [helios-remote-cli-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-windows.exe) |
-# 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//click
-```
+The relay server (`helios-remote-relay`) runs on the VPS and is not distributed.
-## Client (Phase 2)
-
-See [`crates/client/README.md`](crates/client/README.md) for the planned Windows client implementation.
-
-## Development
-
-```bash
-# Build everything
-cargo build
-
-# Run tests
-cargo test
-
-# Run server in dev mode
-RUST_LOG=debug cargo run -p helios-server
-```
+---
## License
MIT
+
+
+
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..a98d4c3
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,104 @@
+# 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 ` → find the window label
+2. `screenshot ` → look at it
+3. `focus ` → bring it to front if needed
+4. `exec` → perform the action
+5. `screenshot ` → 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.
diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..c5c5f00
Binary files /dev/null and b/assets/icon.ico differ
diff --git a/assets/logo.png b/assets/logo.png
index b5087aa..ae8b1d5 100644
Binary files a/assets/logo.png and b/assets/logo.png differ
diff --git a/config.env b/config.env
new file mode 100644
index 0000000..ab3b8c9
--- /dev/null
+++ b/config.env
@@ -0,0 +1,2 @@
+HELIOS_REMOTE_URL=https://remote.agent-helios.me
+HELIOS_REMOTE_API_KEY=SqY8jLUrZugp6N4UhVPq7KDT0CeU2P7
diff --git a/config.env.example b/config.env.example
new file mode 100644
index 0000000..ce4b228
--- /dev/null
+++ b/config.env.example
@@ -0,0 +1,2 @@
+HELIOS_REMOTE_URL=https://your-relay-server.example.com
+HELIOS_REMOTE_API_KEY=your-api-key-here
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
new file mode 100644
index 0000000..94de807
--- /dev/null
+++ b/crates/cli/Cargo.toml
@@ -0,0 +1,16 @@
+[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"
diff --git a/crates/cli/build.rs b/crates/cli/build.rs
new file mode 100644
index 0000000..113e1c6
--- /dev/null
+++ b/crates/cli/build.rs
@@ -0,0 +1,11 @@
+fn main() {
+ // Embed git commit hash at build time
+ let output = std::process::Command::new("git")
+ .args(["log", "-1", "--format=%h"])
+ .output();
+ let commit = match output {
+ Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
+ Err(_) => "unknown".to_string(),
+ };
+ println!("cargo:rustc-env=GIT_COMMIT={commit}");
+}
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
new file mode 100644
index 0000000..17ad146
--- /dev/null
+++ b/crates/cli/src/main.rs
@@ -0,0 +1,888 @@
+use base64::Engine;
+use clap::{Parser, Subcommand};
+use serde_json::{json, Value};
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::process;
+
+const GIT_COMMIT: &str = env!("GIT_COMMIT");
+
+// ── Config ──────────────────────────────────────────────────────────────────
+
+struct Config {
+ base_url: String,
+ api_key: String,
+}
+
+fn load_config() -> Config {
+ let exe = std::env::current_exe().unwrap_or_default();
+ let dir = exe.parent().unwrap_or(Path::new("."));
+ let path = dir.join("config.env");
+
+ let mut map = HashMap::new();
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ for line in content.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+ if let Some((k, v)) = line.split_once('=') {
+ map.insert(k.trim().to_string(), v.trim().to_string());
+ }
+ }
+ }
+
+ let base_url = map
+ .get("HELIOS_REMOTE_URL")
+ .cloned()
+ .unwrap_or_default()
+ .trim_end_matches('/')
+ .to_string();
+ let api_key = map
+ .get("HELIOS_REMOTE_API_KEY")
+ .cloned()
+ .unwrap_or_default();
+
+ if base_url.is_empty() || api_key.is_empty() {
+ eprintln!(
+ "[helios-remote] ERROR: config.env missing or incomplete at {}",
+ path.display()
+ );
+ process::exit(1);
+ }
+
+ Config { base_url, api_key }
+}
+
+// ── HTTP helpers ────────────────────────────────────────────────────────────
+
+fn client() -> reqwest::blocking::Client {
+ reqwest::blocking::Client::new()
+}
+
+fn req(
+ cfg: &Config,
+ method: &str,
+ path: &str,
+ body: Option,
+ timeout_secs: u64,
+) -> Value {
+ let url = format!("{}{}", cfg.base_url, path);
+ let c = client();
+ let timeout = std::time::Duration::from_secs(timeout_secs);
+
+ let builder = match method {
+ "GET" => c.get(&url),
+ "POST" => c.post(&url),
+ _ => c.get(&url),
+ };
+
+ let builder = builder
+ .header("X-Api-Key", &cfg.api_key)
+ .header("Content-Type", "application/json")
+ .timeout(timeout);
+
+ let builder = if let Some(b) = body {
+ builder.body(b.to_string())
+ } else {
+ builder
+ };
+
+ let resp = match builder.send() {
+ Ok(r) => r,
+ Err(e) => {
+ if e.is_timeout() {
+ eprintln!(
+ "[helios-remote] TIMEOUT: {} did not respond within {} s",
+ url, timeout_secs
+ );
+ } else {
+ eprintln!(
+ "[helios-remote] CONNECTION ERROR: Cannot reach {}\n → {}",
+ url, e
+ );
+ }
+ process::exit(1);
+ }
+ };
+
+ let status = resp.status();
+ if !status.is_success() {
+ let body_text = resp.text().unwrap_or_default();
+ let body_preview = if body_text.len() > 1000 {
+ &body_text[..1000]
+ } else {
+ &body_text
+ };
+ eprintln!(
+ "[helios-remote] HTTP {} {}\n URL : {}\n Method : {}\n Body : {}",
+ status.as_u16(),
+ status.canonical_reason().unwrap_or(""),
+ url,
+ method,
+ body_preview,
+ );
+ process::exit(1);
+ }
+
+ resp.json::().unwrap_or(json!({}))
+}
+
+// ── Label validation ────────────────────────────────────────────────────────
+
+fn validate_label(label: &str) {
+ let valid = !label.is_empty()
+ && label
+ .chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
+ && label.chars().next().unwrap().is_ascii_alphanumeric();
+ if !valid {
+ eprintln!(
+ "[helios-remote] Invalid label '{}'. Must be lowercase, no whitespace, only a-z 0-9 - _",
+ label
+ );
+ process::exit(1);
+ }
+}
+
+// ── Window resolution ───────────────────────────────────────────────────────
+
+fn resolve_window(cfg: &Config, device: &str, window_label: &str) -> i64 {
+ let data = req(cfg, "GET", &format!("/devices/{}/windows", device), None, 30);
+ let windows = data["windows"].as_array().cloned().unwrap_or_default();
+ let query = window_label.to_lowercase();
+
+ // Exact label match
+ for w in &windows {
+ if w["visible"].as_bool() == Some(true) && w["label"].as_str() == Some(&query) {
+ return w["id"].as_i64().unwrap();
+ }
+ }
+
+ // Substring match on label
+ let mut matches: Vec<&Value> = windows
+ .iter()
+ .filter(|w| {
+ w["visible"].as_bool() == Some(true)
+ && w["label"]
+ .as_str()
+ .map(|l| l.contains(&query))
+ .unwrap_or(false)
+ })
+ .collect();
+
+ // Fallback: substring on title
+ if matches.is_empty() {
+ matches = windows
+ .iter()
+ .filter(|w| {
+ w["visible"].as_bool() == Some(true)
+ && w["title"]
+ .as_str()
+ .map(|t| t.to_lowercase().contains(&query))
+ .unwrap_or(false)
+ })
+ .collect();
+ }
+
+ if matches.is_empty() {
+ eprintln!(
+ "[helios-remote] No visible window matching '{}'",
+ window_label
+ );
+ process::exit(1);
+ }
+
+ if matches.len() > 1 {
+ println!(
+ "[helios-remote] Multiple matches for '{}', using first:",
+ window_label
+ );
+ for w in &matches {
+ println!(
+ " {:<30} {}",
+ w["label"].as_str().unwrap_or("?"),
+ w["title"].as_str().unwrap_or("")
+ );
+ }
+ }
+
+ matches[0]["id"].as_i64().unwrap()
+}
+
+// ── CLI ─────────────────────────────────────────────────────────────────────
+
+#[derive(Parser)]
+#[command(
+ name = "helios",
+ about = "Control devices connected to the Helios Remote Relay Server."
+)]
+struct Cli {
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// List all connected devices
+ Devices,
+
+ /// Capture screenshot (screen or window label)
+ Screenshot {
+ /// Device label
+ device: String,
+ /// 'screen' for full screen, or a window label
+ target: String,
+ },
+
+ /// Run a shell command on the remote device
+ Exec {
+ /// Device label
+ device: String,
+ /// Timeout in seconds
+ #[arg(long)]
+ timeout: Option,
+ /// Command (and arguments) to execute
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ parts: Vec,
+ },
+
+ /// List all visible windows on the remote device
+ Windows {
+ /// Device label
+ device: String,
+ },
+
+ /// Bring a window to the foreground
+ Focus {
+ /// Device label
+ device: String,
+ /// Window label
+ window: String,
+ },
+
+ /// Maximize and focus a window
+ Maximize {
+ /// Device label
+ device: String,
+ /// Window label
+ window: String,
+ },
+
+ /// Minimize all windows
+ MinimizeAll {
+ /// Device label
+ device: String,
+ },
+
+ /// Show a notification to the user (fire-and-forget, no response needed)
+ Inform {
+ /// Device label
+ device: String,
+ /// Message to display
+ message: String,
+ /// Custom dialog title
+ #[arg(long)]
+ title: Option,
+ },
+
+ /// Launch a program (fire-and-forget)
+ Run {
+ /// Device label
+ device: String,
+ /// Program to launch
+ program: String,
+ /// Program arguments
+ #[arg(trailing_var_arg = true)]
+ args: Vec,
+ },
+
+ /// Get clipboard contents
+ ClipboardGet {
+ /// Device label
+ device: String,
+ },
+
+ /// Set clipboard contents
+ ClipboardSet {
+ /// Device label
+ device: String,
+ /// Text to set
+ text: String,
+ },
+
+ /// Upload a local file to the remote device
+ Upload {
+ /// Device label
+ device: String,
+ /// Local file path
+ local_path: PathBuf,
+ /// Remote file path
+ remote_path: String,
+ },
+
+ /// Download a file from the remote device
+ Download {
+ /// Device label
+ device: String,
+ /// Remote file path
+ remote_path: String,
+ /// Local file path
+ local_path: PathBuf,
+ },
+
+ /// Compare relay, CLI, and client commits
+ Version {
+ /// Device label
+ device: String,
+ },
+
+ /// 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::()
+ .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::()
+ .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::()
+ .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 = 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(""));
+ }
+ }
+}
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index a0a968b..02d3fff 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -1,10 +1,10 @@
[package]
-name = "helios-client"
+name = "helios-remote-client"
version = "0.1.0"
edition = "2021"
[[bin]]
-name = "helios-client"
+name = "helios-remote-client"
path = "src/main.rs"
[dependencies]
@@ -13,13 +13,24 @@ tokio-tungstenite = { version = "0.21", features = ["connect", "native-tls"] }
native-tls = { version = "0.2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+toml = "0.8"
+chrono = "0.4"
helios-common = { path = "../common" }
+uuid = { version = "1", features = ["v4"] }
dirs = "5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22"
png = "0.17"
futures-util = "0.3"
+colored = "2"
+scopeguard = "1"
+reqwest = { version = "0.12", features = ["json"] }
+terminal_size = "0.3"
+unicode-width = "0.1"
+
+[build-dependencies]
+winres = "0.1"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.54", features = [
@@ -28,4 +39,8 @@ windows = { version = "0.54", features = [
"Win32_UI_Input_KeyboardAndMouse",
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
+ "Win32_UI_Shell",
+ "Win32_System_Console",
+ "Win32_System_ProcessStatus",
+ "Win32_Graphics_Dwm",
] }
diff --git a/crates/client/README.md b/crates/client/README.md
index f6000c5..588ef6d 100644
--- a/crates/client/README.md
+++ b/crates/client/README.md
@@ -1,36 +1,40 @@
-# helios-client (Phase 2 — not yet implemented)
+# helios-client
-This crate will contain the Windows remote-control client for `helios-remote`.
+Windows client for helios-remote. Connects to the relay server via WebSocket and executes commands.
-## Planned Features
+## Features
-- Connects to the relay server via WebSocket (`wss://`)
-- Sends a `Hello` message on connect with an optional display label
-- Handles incoming `ServerMessage` commands:
- - `ScreenshotRequest` → captures the primary display (Windows GDI or `windows-capture`) and responds with base64 PNG
- - `ExecRequest` → runs a shell command in a persistent `cmd.exe` / PowerShell session and returns stdout/stderr/exit-code
- - `ClickRequest` → simulates a mouse click via `SendInput` Win32 API
- - `TypeRequest` → types text via `SendInput` (virtual key events)
-- Persistent shell session so `cd C:\Users` persists across `exec` calls
-- Auto-reconnect with exponential backoff
-- Configurable via environment variables or a `client.toml` config file
+- Full-screen and per-window screenshots
+- Shell command execution (persistent PowerShell session)
+- Window management (list, focus, maximize, minimize)
+- File upload/download
+- Clipboard get/set
+- Program launch (fire-and-forget)
+- User prompts (MessageBox)
+- Single instance enforcement (PID lock file)
-## Planned Tech Stack
+## Configuration
-| Crate | Purpose |
-|---|---|
-| `tokio` | Async runtime |
-| `tokio-tungstenite` | WebSocket client |
-| `serde_json` | Protocol serialization |
-| `windows` / `winapi` | Screen capture, mouse/keyboard input |
-| `base64` | PNG encoding for screenshots |
+On first run, the client prompts for:
+- **Relay URL** (default: `wss://remote.agent-helios.me/ws`)
+- **API Key**
+- **Device label** — must be lowercase, no whitespace, only `a-z 0-9 - _`
-## Build Target
+Config is saved to `%APPDATA%/helios-remote/config.toml`.
+## Device Labels
+
+The device label is the sole identifier for this machine. It must follow these rules:
+- Lowercase only
+- No whitespace
+- Only characters: `a-z`, `0-9`, `-`, `_`
+
+Examples: `moritz_pc`, `work-desktop`, `gaming-rig`
+
+If an existing config has an invalid label, it will be automatically migrated on next startup.
+
+## Build
+
+```bash
+cargo build -p helios-client --release
```
-cargo build --target x86_64-pc-windows-gnu
-```
-
-## App Icon
-
-The file `assets/logo.ico` in the repository root is the application icon intended for the Windows `.exe`. It can be embedded at compile time using a build script (e.g. via the `winres` crate).
diff --git a/crates/client/build.rs b/crates/client/build.rs
new file mode 100644
index 0000000..427ef10
--- /dev/null
+++ b/crates/client/build.rs
@@ -0,0 +1,46 @@
+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}");
+ }
+ }
+ }
+}
diff --git a/crates/client/src/display.rs b/crates/client/src/display.rs
new file mode 100644
index 0000000..b5b042b
--- /dev/null
+++ b/crates/client/src/display.rs
@@ -0,0 +1,149 @@
+/// 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: 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!(
+ " {} {: 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::() 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 = 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::() 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())
-}
diff --git a/crates/client/src/logger.rs b/crates/client/src/logger.rs
new file mode 100644
index 0000000..21aa988
--- /dev/null
+++ b/crates/client/src/logger.rs
@@ -0,0 +1,59 @@
+/// 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> = OnceLock::new();
+static LOG_PATH: OnceLock = 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")
+}
diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs
index 468d097..fdd6afa 100644
--- a/crates/client/src/main.rs
+++ b/crates/client/src/main.rs
@@ -2,25 +2,136 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
+use colored::Colorize;
use futures_util::{SinkExt, StreamExt};
use native_tls::TlsConnector;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector};
-use tracing::{error, info, warn};
+use base64::Engine;
use helios_common::{ClientMessage, ServerMessage};
+#[allow(unused_imports)]
+use reqwest;
+use helios_common::protocol::{is_valid_label, sanitize_label};
+mod display;
+mod logger;
mod shell;
mod screenshot;
-mod input;
mod windows_mgmt;
-#[derive(Debug, Serialize, Deserialize)]
+use display::trunc;
+
+fn banner() {
+ println!();
+ println!(" {} {}", "☀ ".yellow().bold(), "HELIOS REMOTE".bold());
+ display::info_line("🔗", "commit:", &env!("GIT_COMMIT").dimmed().to_string());
+}
+
+fn print_device_info(label: &str) {
+ #[cfg(windows)]
+ {
+ let admin = is_admin();
+ let priv_str = if admin {
+ "admin".dimmed().to_string()
+ } else {
+ "no admin".dimmed().to_string()
+ };
+ display::info_line("👤", "privileges:", &priv_str);
+ }
+ #[cfg(not(windows))]
+ display::info_line("👤", "privileges:", &"no admin".dimmed().to_string());
+
+ display::info_line("🖥", "device:", &label.dimmed().to_string());
+ println!();
+}
+
+#[cfg(windows)]
+fn is_admin() -> bool {
+ use windows::Win32::UI::Shell::IsUserAnAdmin;
+ unsafe { IsUserAnAdmin().as_bool() }
+}
+
+#[cfg(windows)]
+fn enable_ansi() {
+ use windows::Win32::System::Console::{
+ GetConsoleMode, GetStdHandle, SetConsoleMode,
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING, STD_OUTPUT_HANDLE,
+ };
+ unsafe {
+ if let Ok(handle) = GetStdHandle(STD_OUTPUT_HANDLE) {
+ let mut mode = Default::default();
+ if GetConsoleMode(handle, &mut mode).is_ok() {
+ let _ = SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
+ }
+ }
+ }
+}
+
+// ── Single instance enforcement ─────────────────────────────────────────────
+
+fn lock_file_path() -> PathBuf {
+ let base = dirs::config_dir()
+ .or_else(|| dirs::home_dir())
+ .unwrap_or_else(|| PathBuf::from("."));
+ base.join("helios-remote").join("instance.lock")
+}
+
+/// Try to acquire a single-instance lock. Returns true if we got it.
+fn acquire_instance_lock() -> bool {
+ let path = lock_file_path();
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+
+ // Check if another instance is running
+ if path.exists() {
+ if let Ok(content) = std::fs::read_to_string(&path) {
+ if let Ok(pid) = content.trim().parse::() {
+ // Check if process is still alive
+ #[cfg(windows)]
+ {
+ use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
+ let alive = unsafe {
+ OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).is_ok()
+ };
+ if alive {
+ return false;
+ }
+ }
+ #[cfg(not(windows))]
+ {
+ use std::process::Command;
+ let alive = Command::new("kill")
+ .args(["-0", &pid.to_string()])
+ .status()
+ .map(|s| s.success())
+ .unwrap_or(false);
+ if alive {
+ return false;
+ }
+ }
+ }
+ }
+ }
+
+ // Write our PID
+ let pid = std::process::id();
+ std::fs::write(&path, pid.to_string()).is_ok()
+}
+
+fn release_instance_lock() {
+ let _ = std::fs::remove_file(lock_file_path());
+}
+
+// ── Config ──────────────────────────────────────────────────────────────────
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
struct Config {
relay_url: String,
- relay_code: String,
- label: Option,
+ api_key: String,
+ label: String,
}
impl Config {
@@ -28,82 +139,148 @@ impl Config {
let base = dirs::config_dir()
.or_else(|| dirs::home_dir())
.unwrap_or_else(|| PathBuf::from("."));
- base.join("helios-remote").join("config.json")
+ base.join("helios-remote").join("config.toml")
}
fn load() -> Option {
let path = Self::config_path();
let data = std::fs::read_to_string(&path).ok()?;
- serde_json::from_str(&data).ok()
+ toml::from_str(&data).ok()
}
fn save(&self) -> std::io::Result<()> {
let path = Self::config_path();
std::fs::create_dir_all(path.parent().unwrap())?;
- let data = serde_json::to_string_pretty(self).unwrap();
+ let data = toml::to_string_pretty(self).unwrap();
std::fs::write(&path, data)?;
Ok(())
}
}
fn prompt_config() -> Config {
+ use std::io::Write;
+
let relay_url = {
- println!("Relay server URL [default: wss://remote.agent-helios.me/ws]: ");
+ let default = "wss://remote.agent-helios.me/ws";
+ print!(" {} Relay URL [{}]: ", "→".cyan().bold(), default);
+ std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim();
if trimmed.is_empty() {
- "wss://remote.agent-helios.me/ws".to_string()
+ default.to_string()
} else {
trimmed.to_string()
}
};
- let relay_code = {
- println!("Enter relay code: ");
+ let api_key = {
+ print!(" {} API Key: ", "→".cyan().bold());
+ std::io::stdout().flush().unwrap();
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
};
let label = {
- println!("Label for this machine (optional, press Enter to skip): ");
- let mut input = String::new();
- std::io::stdin().read_line(&mut input).unwrap();
- let trimmed = input.trim().to_string();
- if trimmed.is_empty() { None } else { Some(trimmed) }
+ let default_label = sanitize_label(&hostname());
+ loop {
+ print!(" {} Device label [{}]: ", "→".cyan().bold(), default_label);
+ std::io::stdout().flush().unwrap();
+ let mut input = String::new();
+ std::io::stdin().read_line(&mut input).unwrap();
+ let trimmed = input.trim();
+ let candidate = if trimmed.is_empty() {
+ default_label.clone()
+ } else {
+ trimmed.to_string()
+ };
+
+ if is_valid_label(&candidate) {
+ break candidate;
+ }
+
+ println!(" {} Label must be lowercase, no spaces. Only a-z, 0-9, '-', '_'.",
+ "✗".red().bold());
+ println!(" Suggestion: {}", sanitize_label(&candidate).cyan());
+ }
};
- Config { relay_url, relay_code, label }
+ Config { relay_url, api_key, label }
}
#[tokio::main]
async fn main() {
- tracing_subscriber::fmt()
- .with_env_filter(
- std::env::var("RUST_LOG")
- .unwrap_or_else(|_| "helios_client=info".to_string()),
- )
- .init();
+ #[cfg(windows)]
+ enable_ansi();
+ logger::init();
+
+ if std::env::var("RUST_LOG").is_err() {
+ unsafe { std::env::set_var("RUST_LOG", "off"); }
+ }
+
+ banner();
+
+ // Clean up leftover .old.exe from previous self-update (Windows can't delete running exe)
+ #[cfg(target_os = "windows")]
+ if let Ok(exe) = std::env::current_exe() {
+ let old = exe.with_extension("old.exe");
+ let _ = std::fs::remove_file(&old);
+ }
+
+ // Single instance check
+ if !acquire_instance_lock() {
+ display::err("❌", "Another instance of helios-remote is already running.");
+ display::err("", "Only one instance per device is allowed.");
+ std::process::exit(1);
+ }
+
+ // Clean up lock on exit
+ let _guard = scopeguard::guard((), |_| release_instance_lock());
// Load or prompt for config
let config = match Config::load() {
Some(c) => {
- info!("Loaded config from {:?}", Config::config_path());
- c
+ // Validate existing label
+ if !is_valid_label(&c.label) {
+ let new_label = sanitize_label(&c.label);
+ display::info_line("⚠", "migrate:", &format!(
+ "Label '{}' is invalid, migrating to '{}'", c.label, new_label
+ ));
+ let mut cfg = c;
+ cfg.label = new_label;
+ if let Err(e) = cfg.save() {
+ display::err("❌", &format!("Failed to save config: {e}"));
+ }
+ cfg
+ } else {
+ c
+ }
}
None => {
- info!("No config found — prompting for setup");
+ display::info_line("ℹ", "setup:", "No config found — first-time setup");
+ println!();
let c = prompt_config();
+ println!();
if let Err(e) = c.save() {
- error!("Failed to save config: {e}");
+ display::err("❌", &format!("Failed to save config: {e}"));
} else {
- info!("Config saved to {:?}", Config::config_path());
+ display::info_line("✅", "config:", "saved");
}
- c
+ // Self-restart after first-time setup so all config takes effect cleanly
+ println!();
+ display::info_line("🔄", "restart:", "Config saved. Restarting...");
+ release_instance_lock();
+ let exe = std::env::current_exe().expect("Failed to get current exe path");
+ let args: Vec = 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 shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
@@ -112,43 +289,49 @@ async fn main() {
const MAX_BACKOFF: Duration = Duration::from_secs(30);
loop {
- info!("Connecting to {}", config.relay_url);
- // Build TLS connector - accepts self-signed certs for internal CA (Caddy tls internal)
+ let host = config.relay_url
+ .trim_start_matches("wss://")
+ .trim_start_matches("ws://")
+ .split('/')
+ .next()
+ .unwrap_or(&config.relay_url);
+
+ display::cmd_start("🌐", "connect", host);
+
let tls_connector = TlsConnector::builder()
.danger_accept_invalid_certs(true)
.build()
.expect("TLS connector build failed");
let connector = Connector::NativeTls(tls_connector);
+
match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await {
Ok((ws_stream, _)) => {
- info!("Connected!");
- backoff = Duration::from_secs(1); // reset on success
+ display::cmd_done("🌐", "connect", host, true, "connected");
+ backoff = Duration::from_secs(1);
let (mut write, mut read) = ws_stream.split();
- // Send Hello
+ // Send Hello with device label
let hello = ClientMessage::Hello {
- label: config.label.clone(),
+ label: label.clone(),
};
let hello_json = serde_json::to_string(&hello).unwrap();
if let Err(e) = write.send(Message::Text(hello_json)).await {
- error!("Failed to send Hello: {e}");
+ display::err("❌", &format!("hello failed: {e}"));
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
continue;
}
- // Shared write half
let write = Arc::new(Mutex::new(write));
- // Process messages
while let Some(msg_result) = read.next().await {
match msg_result {
Ok(Message::Text(text)) => {
let server_msg: ServerMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
- warn!("Failed to parse server message: {e}\nRaw: {text}");
+ display::err("❌", &format!("Failed to parse server message: {e}"));
continue;
}
};
@@ -158,10 +341,16 @@ async fn main() {
tokio::spawn(async move {
let response = handle_message(server_msg, shell_clone).await;
- let json = serde_json::to_string(&response).unwrap();
+ let json = match serde_json::to_string(&response) {
+ Ok(j) => j,
+ Err(e) => {
+ display::err("❌", &format!("Failed to serialize response: {e}"));
+ return;
+ }
+ };
let mut w = write_clone.lock().await;
if let Err(e) = w.send(Message::Text(json)).await {
- error!("Failed to send response: {e}");
+ display::err("❌", &format!("Failed to send response: {e}"));
}
});
}
@@ -170,21 +359,21 @@ async fn main() {
let _ = w.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
- info!("Server closed connection");
+ display::cmd_start("🌐", "connect", host);
+ display::cmd_done("🌐", "connect", host, false, "connection lost");
break;
}
Err(e) => {
- error!("WebSocket error: {e}");
+ display::cmd_done("🌐", "connect", host, false, &format!("lost: {e}"));
break;
}
_ => {}
}
}
-
- warn!("Disconnected. Reconnecting in {:?}...", backoff);
}
Err(e) => {
- error!("Connection failed: {e}");
+ display::cmd_start("🌐", "connect", host);
+ display::cmd_done("🌐", "connect", host, false, &format!("{e}"));
}
}
@@ -193,138 +382,377 @@ 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;
+ fn unwrap_or_else(self, f: impl FnOnce() -> String) -> String;
+}
+
+impl OrElseString for String {
+ fn or_else(self, f: impl FnOnce() -> Option) -> String {
+ if self.is_empty() { f().unwrap_or_default() } else { self }
+ }
+ fn unwrap_or_else(self, f: impl FnOnce() -> String) -> String {
+ if self.is_empty() { f() } else { self }
+ }
+}
+
async fn handle_message(
msg: ServerMessage,
shell: Arc>,
) -> ClientMessage {
match msg {
+ ServerMessage::WindowScreenshotRequest { request_id, window_id } => {
+ let payload = format!("window {window_id}");
+ display::cmd_start("📷", "screenshot", &payload);
+ match screenshot::take_window_screenshot(window_id) {
+ Ok((image_base64, width, height)) => {
+ display::cmd_done("📷", "screenshot", &payload, true, &format!("{width}×{height}"));
+ ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
+ }
+ Err(e) => {
+ display::cmd_done("📷", "screenshot", &payload, false, &format!("{e}"));
+ ClientMessage::Error { request_id, message: format!("Window screenshot failed: {e}") }
+ }
+ }
+ }
+
ServerMessage::ScreenshotRequest { request_id } => {
+ display::cmd_start("📷", "screenshot", "screen");
match screenshot::take_screenshot() {
- Ok((image_base64, width, height)) => ClientMessage::ScreenshotResponse {
- request_id,
- image_base64,
- width,
- height,
- },
+ Ok((image_base64, width, height)) => {
+ display::cmd_done("📷", "screenshot", "screen", true, &format!("{width}×{height}"));
+ ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
+ }
Err(e) => {
- error!("Screenshot failed: {e}");
- ClientMessage::Error {
- request_id,
- message: format!("Screenshot failed: {e}"),
- }
+ display::cmd_done("📷", "screenshot", "screen", false, &format!("{e}"));
+ ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") }
}
}
}
- ServerMessage::ExecRequest { request_id, command } => {
- info!("Exec: {command}");
+ ServerMessage::InformRequest { request_id, message, title } => {
+ let msg = message.clone();
+ let ttl = title.clone().unwrap_or_else(|| "Helios".to_string());
+ // Fire-and-forget: show MessageBox in background thread, don't block
+ std::thread::spawn(move || {
+ #[cfg(windows)]
+ unsafe {
+ use windows::core::PCWSTR;
+ use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, HWND_DESKTOP};
+ let msg_w: Vec = msg.encode_utf16().chain(std::iter::once(0)).collect();
+ let ttl_w: Vec = 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;
- match sh.run(&command).await {
- Ok((stdout, stderr, exit_code)) => ClientMessage::ExecResponse {
- request_id,
- stdout,
- stderr,
- exit_code,
- },
- Err(e) => {
- error!("Exec failed for command {:?}: {e}", command);
- ClientMessage::Error {
- request_id,
- message: format!(
- "Exec failed for command {:?}.\nError: {e}\nContext: persistent shell may have died.",
- command
- ),
- }
+ match sh.run(&command, timeout_ms).await {
+ Ok((stdout, stderr, exit_code)) => {
+ let result = if exit_code != 0 {
+ let err_line = stderr.lines()
+ .map(|l| l.trim())
+ .find(|l| !l.is_empty()
+ && !l.starts_with("In Zeile:")
+ && !l.starts_with("+ ")
+ && !l.starts_with("CategoryInfo")
+ && !l.starts_with("FullyQualifiedErrorId"))
+ .unwrap_or("error")
+ .to_string();
+ err_line
+ } else {
+ stdout.trim().lines().next().unwrap_or("").to_string()
+ };
+ display::cmd_done("⚡", "execute", &payload, exit_code == 0, &result);
+ ClientMessage::ExecResponse { request_id, stdout, stderr, exit_code }
}
- }
- }
-
- 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}"),
- }
+ display::cmd_done("⚡", "execute", &payload, false, &format!("exec failed: {e}"));
+ ClientMessage::Error { request_id, message: format!("Exec failed for command {:?}.\nError: {e}", command) }
}
}
}
ServerMessage::ListWindowsRequest { request_id } => {
- info!("ListWindows");
+ display::cmd_start("🪟", "list windows", "");
match windows_mgmt::list_windows() {
- Ok(windows) => ClientMessage::ListWindowsResponse { request_id, windows },
+ Ok(windows) => {
+ display::cmd_done("🪟", "list windows", "", true, &format!("{} windows", windows.len()));
+ ClientMessage::ListWindowsResponse { request_id, windows }
+ }
Err(e) => {
- error!("ListWindows failed: {e}");
+ display::cmd_done("🪟", "list windows", "", false, &e);
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::MinimizeAllRequest { request_id } => {
- info!("MinimizeAll");
+ display::cmd_start("🪟", "minimize all", "");
match windows_mgmt::minimize_all() {
- Ok(()) => ClientMessage::Ack { request_id },
+ Ok(()) => {
+ display::cmd_done("🪟", "minimize all", "", true, "done");
+ ClientMessage::Ack { request_id }
+ }
Err(e) => {
- error!("MinimizeAll failed: {e}");
+ display::cmd_done("🪟", "minimize all", "", false, &e);
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::FocusWindowRequest { request_id, window_id } => {
- info!("FocusWindow: {window_id}");
+ let payload = format!("{window_id}");
+ display::cmd_start("🪟", "focus window", &payload);
match windows_mgmt::focus_window(window_id) {
- Ok(()) => ClientMessage::Ack { request_id },
+ Ok(()) => {
+ display::cmd_done("🪟", "focus window", &payload, true, "done");
+ ClientMessage::Ack { request_id }
+ }
Err(e) => {
- error!("FocusWindow failed: {e}");
+ display::cmd_done("🪟", "focus window", &payload, false, &e);
ClientMessage::Error { request_id, message: e }
}
}
}
ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => {
- info!("MaximizeAndFocus: {window_id}");
+ let payload = format!("{window_id}");
+ display::cmd_start("🪟", "maximize", &payload);
match windows_mgmt::maximize_and_focus(window_id) {
- Ok(()) => ClientMessage::Ack { request_id },
+ Ok(()) => {
+ display::cmd_done("🪟", "maximize", &payload, true, "done");
+ ClientMessage::Ack { request_id }
+ }
Err(e) => {
- error!("MaximizeAndFocus failed: {e}");
+ display::cmd_done("🪟", "maximize", &payload, false, &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 = std::env::args().skip(1).collect();
+ #[cfg(target_os = "windows")]
+ {
+ // Use "start" to open a new visible console window
+ let exe_str = exe.to_string_lossy();
+ let _ = std::process::Command::new("cmd")
+ .args(["/c", "start", "", &exe_str])
+ .spawn();
+ }
+ #[cfg(not(target_os = "windows"))]
+ let _ = std::process::Command::new(&exe).args(&args).spawn();
+ std::process::exit(0);
+ });
+ display::cmd_done("🔄", "update", "", true, "triggered");
+ ClientMessage::UpdateResponse {
+ request_id,
+ success: true,
+ message: "update triggered, client restarting...".into(),
+ }
+ }
+
ServerMessage::Ack { request_id } => {
- info!("Server ack for {request_id}");
- // Nothing to do - server acked something we sent
ClientMessage::Ack { request_id }
}
ServerMessage::Error { request_id, message } => {
- 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
+ display::err("❌", &format!("server error: {message}"));
if let Some(rid) = request_id {
ClientMessage::Ack { request_id: rid }
} else {
- ClientMessage::Hello { label: None }
+ ClientMessage::Hello { label: String::new() }
}
}
}
diff --git a/crates/client/src/screenshot.rs b/crates/client/src/screenshot.rs
index d867ea4..cc633eb 100644
--- a/crates/client/src/screenshot.rs
+++ b/crates/client/src/screenshot.rs
@@ -3,10 +3,10 @@ use base64::Engine;
#[cfg(windows)]
pub fn take_screenshot() -> Result<(String, u32, u32), String> {
- use windows::Win32::Foundation::RECT;
+
use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
- GetDIBits, GetObjectW, SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
+ GetDIBits, SelectObject, BITMAPINFO, BITMAPINFOHEADER,
DIB_RGB_COLORS, SRCCOPY,
};
use windows::Win32::UI::WindowsAndMessaging::GetDesktopWindow;
@@ -117,6 +117,60 @@ pub fn take_screenshot() -> Result<(String, u32, u32), String> {
}
}
+/// Capture a specific window by cropping the full screen to its rect.
+#[cfg(windows)]
+pub fn take_window_screenshot(window_id: u64) -> Result<(String, u32, u32), String> {
+ use windows::Win32::Foundation::{HWND, RECT};
+ use windows::Win32::UI::WindowsAndMessaging::GetWindowRect;
+
+ let hwnd = HWND(window_id as isize);
+ let (x, y, w, h) = unsafe {
+ let mut rect = RECT::default();
+ GetWindowRect(hwnd, &mut rect).map_err(|e| format!("GetWindowRect failed: {e}"))?;
+ let w = (rect.right - rect.left) as u32;
+ let h = (rect.bottom - rect.top) as u32;
+ if w == 0 || h == 0 { return Err(format!("Window has zero size: {w}x{h}")); }
+ (rect.left, rect.top, w, h)
+ };
+
+ // Take full screenshot and crop to window rect
+ let (full_b64, full_w, full_h) = take_screenshot()?;
+ let full_bytes = base64::engine::general_purpose::STANDARD
+ .decode(&full_b64).map_err(|e| format!("base64 decode: {e}"))?;
+
+ // Decode PNG back to raw RGBA
+ let cursor = std::io::Cursor::new(&full_bytes);
+ let decoder = png::Decoder::new(cursor);
+ let mut reader = decoder.read_info().map_err(|e| format!("PNG decode: {e}"))?;
+ let mut img_buf = vec![0u8; reader.output_buffer_size()];
+ reader.next_frame(&mut img_buf).map_err(|e| format!("PNG frame: {e}"))?;
+
+ // Clamp window rect to screen bounds
+ let x0 = (x.max(0) as u32).min(full_w);
+ let y0 = (y.max(0) as u32).min(full_h);
+ let x1 = ((x as u32 + w)).min(full_w);
+ let y1 = ((y as u32 + h)).min(full_h);
+ let cw = x1 - x0;
+ let ch = y1 - y0;
+
+ // Crop: 4 bytes per pixel (RGBA)
+ let mut cropped = Vec::with_capacity((cw * ch * 4) as usize);
+ for row in y0..y1 {
+ let start = ((row * full_w + x0) * 4) as usize;
+ let end = start + (cw * 4) as usize;
+ cropped.extend_from_slice(&img_buf[start..end]);
+ }
+
+ let png_bytes = encode_png(&cropped, cw, ch)?;
+ let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
+ Ok((b64, cw, ch))
+}
+
+#[cfg(not(windows))]
+pub fn take_window_screenshot(_window_id: u64) -> Result<(String, u32, u32), String> {
+ Err("Window screenshot only supported on Windows".to_string())
+}
+
#[cfg(not(windows))]
pub fn take_screenshot() -> Result<(String, u32, u32), String> {
// Stub for non-Windows builds
diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs
index 39addca..ad4b4bd 100644
--- a/crates/client/src/shell.rs
+++ b/crates/client/src/shell.rs
@@ -1,161 +1,51 @@
-/// Persistent shell session that keeps a cmd.exe (Windows) or sh (Unix) alive
-/// between commands, so state like `cd` is preserved.
-use std::process::Stdio;
-use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
-use tokio::process::{Child, ChildStdin, ChildStdout, ChildStderr};
-use tracing::{debug, warn};
+/// Shell execution — each command runs in its own fresh process.
+/// On Windows we use powershell.exe -NoProfile so the user's $PROFILE
+/// (which might run `clear`) is never loaded.
+use std::time::Duration;
+use tokio::process::Command;
-const OUTPUT_TIMEOUT_MS: u64 = 10_000;
-/// Unique sentinel appended after every command to know when output is done.
-const SENTINEL: &str = "__HELIOS_DONE__";
+const DEFAULT_TIMEOUT_MS: u64 = 30_000;
-pub struct PersistentShell {
- child: Option,
-}
-
-struct ShellProcess {
- _child: Child,
- stdin: ChildStdin,
- stdout_lines: tokio::sync::Mutex>,
- stderr_lines: tokio::sync::Mutex>,
-}
+pub struct PersistentShell;
impl PersistentShell {
- pub fn new() -> Self {
- Self { child: None }
- }
+ pub fn new() -> Self { Self }
- async fn spawn(&mut self) -> Result<(), String> {
+ pub async fn run(&mut self, command: &str, timeout_ms: Option) -> Result<(String, String, i32), String> {
+ let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
+ let timeout = Duration::from_millis(timeout_ms);
#[cfg(windows)]
- let (program, args) = ("cmd.exe", vec!["/Q"]);
- #[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 reader = shell.stdout_lines.lock().await;
- loop {
- let mut line = String::new();
- let read_fut = reader.read_line(&mut line);
- match tokio::time::timeout(timeout, read_fut).await {
- Ok(Ok(0)) => {
- return Err("Shell stdout EOF — process likely died".to_string());
- }
- Ok(Ok(_)) => {
- debug!("stdout line: {line:?}");
- if line.trim_end().starts_with(SENTINEL) {
- // Parse exit code from sentinel line
- let code_str = line.trim_end().trim_start_matches(SENTINEL);
- exit_code = code_str.trim().parse().unwrap_or(0);
- break;
- } else {
- stdout_buf.push_str(&line);
- }
- }
- Ok(Err(e)) => {
- return Err(format!("Shell stdout read error: {e}"));
- }
- Err(_) => {
- return Err(format!(
- "Shell stdout timed out after {}ms waiting for command to finish.\nCommand: {command}\nOutput so far: {stdout_buf}",
- OUTPUT_TIMEOUT_MS
- ));
- }
- }
- }
+ let mut cmd = Command::new("powershell.exe");
+ cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]);
+ run_captured(cmd, timeout).await
}
-
- // Drain available stderr (non-blocking)
- let mut stderr_buf = String::new();
+ #[cfg(not(windows))]
{
- 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,
- }
- }
+ let mut cmd = Command::new("sh");
+ cmd.args(["-c", command]);
+ run_captured(cmd, timeout).await
}
-
- Ok((stdout_buf, stderr_buf, exit_code))
+ }
+}
+
+async fn run_captured(
+ mut cmd: Command,
+ timeout: Duration,
+) -> Result<(String, String, i32), String> {
+ cmd.stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped());
+
+ let child = cmd.spawn()
+ .map_err(|e| format!("Failed to spawn process: {e}"))?;
+
+ match tokio::time::timeout(timeout, child.wait_with_output()).await {
+ Ok(Ok(out)) => Ok((
+ String::from_utf8_lossy(&out.stdout).into_owned(),
+ String::from_utf8_lossy(&out.stderr).into_owned(),
+ out.status.code().unwrap_or(-1),
+ )),
+ Ok(Err(e)) => Err(format!("Process wait failed: {e}")),
+ Err(_) => Err(format!("Command timed out after {}ms", timeout.as_millis())),
}
}
diff --git a/crates/client/src/windows_mgmt.rs b/crates/client/src/windows_mgmt.rs
index 465cb96..8dfb7a6 100644
--- a/crates/client/src/windows_mgmt.rs
+++ b/crates/client/src/windows_mgmt.rs
@@ -1,18 +1,27 @@
-use helios_common::protocol::WindowInfo;
+use helios_common::protocol::{sanitize_label, WindowInfo};
+use std::collections::HashMap;
// ── Windows implementation ──────────────────────────────────────────────────
#[cfg(windows)]
mod win_impl {
use super::*;
- use std::sync::Mutex;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
+ use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED};
use windows::Win32::UI::WindowsAndMessaging::{
- BringWindowToTop, EnumWindows, GetWindowTextW, IsWindowVisible, SetForegroundWindow,
- ShowWindow, SW_MAXIMIZE, SW_MINIMIZE, SHOW_WINDOW_CMD,
+ BringWindowToTop, EnumWindows, GetWindowTextW,
+ IsWindowVisible, SetForegroundWindow, ShowWindow,
+ 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 {
let list = &mut *(lparam.0 as *mut Vec);
list.push(hwnd);
@@ -30,25 +39,123 @@ mod win_impl {
list
}
+ fn is_cloaked(hwnd: HWND) -> bool {
+ let mut cloaked: u32 = 0;
+ unsafe {
+ DwmGetWindowAttribute(
+ hwnd,
+ DWMWA_CLOAKED,
+ &mut cloaked as *mut u32 as *mut _,
+ std::mem::size_of::() as u32,
+ ).is_err() == false && cloaked != 0
+ }
+ }
+
fn hwnd_title(hwnd: HWND) -> String {
let mut buf = [0u16; 512];
let len = unsafe { GetWindowTextW(hwnd, &mut buf) };
String::from_utf16_lossy(&buf[..len as usize])
}
+ /// Get the process name (exe without extension) for a window handle.
+ /// Tries `GetModuleBaseNameW` first, then `QueryFullProcessImageNameW`
+ /// (which works for elevated processes with `PROCESS_QUERY_LIMITED_INFORMATION`).
+ fn hwnd_process_name(hwnd: HWND) -> Option {
+ 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, String> {
let hwnds = get_all_hwnds();
- let mut windows = Vec::new();
- for hwnd in hwnds {
- let visible = unsafe { IsWindowVisible(hwnd).as_bool() };
- let title = hwnd_title(hwnd);
+
+ // Collect visible windows with non-empty titles
+ let mut raw_windows: Vec<(HWND, String, String)> = Vec::new();
+ for hwnd in &hwnds {
+ let visible = unsafe { IsWindowVisible(*hwnd).as_bool() };
+ if !visible {
+ continue;
+ }
+ if is_cloaked(*hwnd) {
+ continue;
+ }
+ let title = hwnd_title(*hwnd);
if title.is_empty() {
continue;
}
+ // "Program Manager" is always the Windows desktop shell, never a real window
+ if title.trim().eq_ignore_ascii_case("program manager") {
+ continue;
+ }
+ let process_name = hwnd_process_name(*hwnd).unwrap_or_default();
+ let proc_lower = process_name.to_lowercase();
+ // ApplicationFrameHost is a UWP container — always a duplicate of the real app window
+ // MsEdgeWebView2 is an embedded browser component, never a standalone user window
+ if proc_lower == "applicationframehost" || proc_lower == "msedgewebview2" {
+ continue;
+ }
+ raw_windows.push((*hwnd, title, process_name));
+ }
+
+ // Generate labels with dedup numbering
+ let mut label_index: HashMap = HashMap::new();
+ let mut windows = Vec::new();
+ for (hwnd, title, proc_name) in raw_windows {
+ let base = if proc_name.is_empty() {
+ sanitize_label(&title)
+ } else {
+ sanitize_label(&proc_name)
+ };
+ if base.is_empty() {
+ continue;
+ }
+ let idx = label_index.entry(base.clone()).or_insert(0);
+ *idx += 1;
+ let label = if *idx == 1 {
+ base.clone()
+ } else {
+ format!("{}{}", base, idx)
+ };
windows.push(WindowInfo {
id: hwnd.0 as u64,
title,
- visible,
+ label,
+ visible: true,
});
}
Ok(windows)
@@ -68,12 +175,17 @@ mod win_impl {
Ok(())
}
+ unsafe fn force_foreground(hwnd: HWND) {
+ keybd_event(VK_MENU.0 as u8, 0, Default::default(), 0);
+ keybd_event(VK_MENU.0 as u8, 0, KEYEVENTF_KEYUP, 0);
+ ShowWindow(hwnd, SW_RESTORE);
+ BringWindowToTop(hwnd).ok();
+ SetForegroundWindow(hwnd);
+ }
+
pub fn focus_window(window_id: u64) -> Result<(), String> {
let hwnd = HWND(window_id as isize);
- unsafe {
- BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?;
- SetForegroundWindow(hwnd);
- }
+ unsafe { force_foreground(hwnd); }
Ok(())
}
@@ -81,8 +193,7 @@ mod win_impl {
let hwnd = HWND(window_id as isize);
unsafe {
ShowWindow(hwnd, SW_MAXIMIZE);
- BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?;
- SetForegroundWindow(hwnd);
+ force_foreground(hwnd);
}
Ok(())
}
diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs
index 3f9a8f9..25b4ceb 100644
--- a/crates/common/src/protocol.rs
+++ b/crates/common/src/protocol.rs
@@ -1,36 +1,74 @@
use serde::{Deserialize, Serialize};
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)]
pub struct WindowInfo {
pub id: u64,
pub title: String,
+ pub label: String,
pub visible: bool,
}
+/// Validate a device/window label: lowercase, no whitespace, only a-z 0-9 - _
+pub fn is_valid_label(s: &str) -> bool {
+ !s.is_empty()
+ && s.chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
+}
+
+/// Convert an arbitrary string into a valid label.
+/// Lowercase, replace whitespace and invalid chars with '_', collapse runs.
+pub fn sanitize_label(s: &str) -> String {
+ let mut result = String::with_capacity(s.len());
+ let mut prev_underscore = false;
+ for c in s.chars() {
+ if c.is_ascii_alphanumeric() {
+ result.push(c.to_ascii_lowercase());
+ prev_underscore = false;
+ } else if c == '-' {
+ result.push('-');
+ prev_underscore = false;
+ } else {
+ // Replace whitespace and other chars with _
+ if !prev_underscore && !result.is_empty() {
+ result.push('_');
+ prev_underscore = true;
+ }
+ }
+ }
+ // Trim trailing _
+ result.trim_end_matches('_').to_string()
+}
+
/// Messages sent from the relay server to a connected client
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
- /// Request a screenshot from the client
+ /// Request a full-screen screenshot
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,
+ },
+ /// Show a non-blocking notification to the user (fire-and-forget)
+ InformRequest {
+ request_id: Uuid,
+ message: String,
+ title: Option,
+ },
/// Execute a shell command on the client
ExecRequest {
request_id: Uuid,
command: String,
- },
- /// 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,
+ timeout_ms: Option,
},
/// Acknowledge a client message
Ack { request_id: Uuid },
@@ -47,14 +85,39 @@ pub enum ServerMessage {
FocusWindowRequest { request_id: Uuid, window_id: u64 },
/// Maximize a window and bring it to the foreground
MaximizeAndFocusRequest { request_id: Uuid, window_id: u64 },
+ /// Request client version info
+ VersionRequest { request_id: Uuid },
+ /// Upload a file to the client
+ UploadRequest {
+ request_id: Uuid,
+ path: String,
+ content_base64: String,
+ },
+ /// Download a file from the client
+ DownloadRequest {
+ request_id: Uuid,
+ path: String,
+ },
+ /// Launch a program on the client (fire-and-forget)
+ RunRequest {
+ request_id: Uuid,
+ program: String,
+ args: Vec,
+ },
+ /// Get the contents of the client's clipboard
+ ClipboardGetRequest { request_id: Uuid },
+ /// Set the contents of the client's clipboard
+ ClipboardSetRequest { request_id: Uuid, text: String },
+ /// Request client to self-update and restart
+ UpdateRequest { request_id: Uuid },
}
/// Messages sent from the client to the relay server
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
- /// Client registers itself with optional display name
- Hello { label: Option },
+ /// Client registers itself with its device label
+ Hello { label: String },
/// Response to a screenshot request — base64-encoded PNG
ScreenshotResponse {
request_id: Uuid,
@@ -69,7 +132,7 @@ pub enum ClientMessage {
stderr: String,
exit_code: i32,
},
- /// Generic acknowledgement for click/type/minimize-all/focus/maximize
+ /// Generic acknowledgement
Ack { request_id: Uuid },
/// Client error response
Error {
@@ -81,32 +144,64 @@ pub enum ClientMessage {
request_id: Uuid,
windows: Vec,
},
-}
-
-/// Mouse button variants
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum MouseButton {
- Left,
- Right,
- Middle,
-}
-
-impl Default for MouseButton {
- fn default() -> Self {
- MouseButton::Left
- }
+ /// Response to a version request
+ VersionResponse {
+ request_id: Uuid,
+ version: String,
+ commit: String,
+ },
+ LogsResponse {
+ request_id: Uuid,
+ content: String,
+ log_path: String,
+ },
+ /// Response to a download request
+ DownloadResponse {
+ request_id: Uuid,
+ content_base64: String,
+ size: u64,
+ },
+ /// Response to a clipboard-get request
+ ClipboardGetResponse { request_id: Uuid, text: String },
+ /// Response to a prompt request
+ PromptResponse { request_id: Uuid, answer: String },
+ /// Response to an update request
+ UpdateResponse {
+ request_id: Uuid,
+ success: bool,
+ message: String,
+ },
}
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn test_valid_labels() {
+ assert!(is_valid_label("moritz_pc"));
+ assert!(is_valid_label("my-desktop"));
+ assert!(is_valid_label("pc01"));
+ assert!(!is_valid_label("Moritz PC"));
+ assert!(!is_valid_label(""));
+ assert!(!is_valid_label("has spaces"));
+ assert!(!is_valid_label("UPPER"));
+ }
+
+ #[test]
+ fn test_sanitize_label() {
+ assert_eq!(sanitize_label("Moritz PC"), "moritz_pc");
+ assert_eq!(sanitize_label("My Desktop!!"), "my_desktop");
+ assert_eq!(sanitize_label("hello-world"), "hello-world");
+ assert_eq!(sanitize_label("DESKTOP-ABC123"), "desktop-abc123");
+ }
+
#[test]
fn test_server_message_serialization() {
let msg = ServerMessage::ExecRequest {
request_id: Uuid::nil(),
command: "echo hello".into(),
+ timeout_ms: None,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("exec_request"));
@@ -115,25 +210,9 @@ mod tests {
#[test]
fn test_client_message_serialization() {
- let msg = ClientMessage::Hello { label: Some("test-pc".into()) };
+ let msg = ClientMessage::Hello { label: "test-pc".into() };
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("hello"));
assert!(json.contains("test-pc"));
}
-
- #[test]
- fn test_roundtrip() {
- let msg = ClientMessage::ExecResponse {
- request_id: Uuid::nil(),
- stdout: "hello\n".into(),
- stderr: String::new(),
- exit_code: 0,
- };
- let json = serde_json::to_string(&msg).unwrap();
- let decoded: ClientMessage = serde_json::from_str(&json).unwrap();
- match decoded {
- ClientMessage::ExecResponse { exit_code, .. } => assert_eq!(exit_code, 0),
- _ => panic!("wrong variant"),
- }
- }
}
diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml
index 14a4f8b..06d9a3c 100644
--- a/crates/server/Cargo.toml
+++ b/crates/server/Cargo.toml
@@ -1,10 +1,10 @@
[package]
-name = "helios-server"
+name = "helios-remote-relay"
version = "0.1.0"
edition = "2021"
[[bin]]
-name = "helios-server"
+name = "helios-remote-relay"
path = "src/main.rs"
[dependencies]
@@ -22,3 +22,4 @@ tokio-tungstenite = "0.21"
futures-util = "0.3"
dashmap = "5"
anyhow = "1"
+reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
diff --git a/crates/server/build.rs b/crates/server/build.rs
new file mode 100644
index 0000000..6754ee2
--- /dev/null
+++ b/crates/server/build.rs
@@ -0,0 +1,11 @@
+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");
+}
diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs
index c670abc..3395c7d 100644
--- a/crates/server/src/api.rs
+++ b/crates/server/src/api.rs
@@ -1,6 +1,6 @@
use std::time::Duration;
use axum::{
- extract::{Path, State},
+ extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use tracing::error;
-use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage};
+use helios_common::protocol::{ClientMessage, ServerMessage};
use crate::AppState;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@@ -21,33 +21,29 @@ pub struct ErrorBody {
pub error: String,
}
-fn not_found(session_id: &str) -> (StatusCode, Json) {
+fn not_found(label: &str) -> (StatusCode, Json) {
(
StatusCode::NOT_FOUND,
Json(ErrorBody {
- error: format!("Session '{session_id}' not found or not connected"),
+ error: format!("Device '{label}' not found or not connected"),
}),
)
}
-fn timeout_error(session_id: &str, op: &str) -> (StatusCode, Json) {
+fn timeout_error(label: &str, op: &str) -> (StatusCode, Json) {
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorBody {
- error: format!(
- "Timed out waiting for client response (session='{session_id}', op='{op}')"
- ),
+ error: format!("Timed out waiting for client response (device='{label}', op='{op}')"),
}),
)
}
-fn send_error(session_id: &str, op: &str) -> (StatusCode, Json) {
+fn send_error(label: &str, op: &str) -> (StatusCode, Json) {
(
StatusCode::BAD_GATEWAY,
Json(ErrorBody {
- error: format!(
- "Failed to send command to client — client may have disconnected (session='{session_id}', op='{op}')"
- ),
+ error: format!("Failed to send command to client — may have disconnected (device='{label}', op='{op}')"),
}),
)
}
@@ -56,282 +52,477 @@ fn send_error(session_id: &str, op: &str) -> (StatusCode, Json) {
async fn dispatch(
state: &AppState,
- session_id: &str,
+ label: &str,
op: &str,
make_msg: F,
) -> Result)>
where
F: FnOnce(Uuid) -> ServerMessage,
{
- let id = session_id.parse::().map_err(|_| {
- (
- StatusCode::BAD_REQUEST,
- Json(ErrorBody {
- error: format!("Invalid session id: '{session_id}'"),
- }),
- )
- })?;
+ dispatch_with_timeout(state, label, op, make_msg, REQUEST_TIMEOUT).await
+}
+async fn dispatch_with_timeout(
+ state: &AppState,
+ label: &str,
+ op: &str,
+ make_msg: F,
+ timeout: Duration,
+) -> Result)>
+where
+ F: FnOnce(Uuid) -> ServerMessage,
+{
let tx = state
.sessions
- .get_cmd_tx(&id)
- .ok_or_else(|| not_found(session_id))?;
+ .get_cmd_tx(label)
+ .ok_or_else(|| not_found(label))?;
let request_id = Uuid::new_v4();
let rx = state.sessions.register_pending(request_id);
let msg = make_msg(request_id);
tx.send(msg).await.map_err(|e| {
- error!("Channel send failed for session={session_id}, op={op}: {e}");
- send_error(session_id, op)
+ error!("Channel send failed for device={label}, op={op}: {e}");
+ send_error(label, op)
})?;
- match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
+ match tokio::time::timeout(timeout, rx).await {
Ok(Ok(response)) => Ok(response),
- Ok(Err(_)) => Err(send_error(session_id, op)),
- Err(_) => Err(timeout_error(session_id, op)),
+ Ok(Err(_)) => Err(send_error(label, op)),
+ Err(_) => Err(timeout_error(label, op)),
}
}
// ── Handlers ─────────────────────────────────────────────────────────────────
-/// GET /sessions — list all connected clients
-pub async fn list_sessions(State(state): State) -> Json {
- let sessions = state.sessions.list();
- Json(serde_json::json!({ "sessions": sessions }))
+/// GET /devices — list all connected clients
+pub async fn list_devices(State(state): State) -> Json {
+ let devices = state.sessions.list();
+ Json(serde_json::json!({ "devices": devices }))
}
-/// POST /sessions/:id/screenshot
+/// POST /devices/:label/screenshot — full screen screenshot
pub async fn request_screenshot(
- Path(session_id): Path,
+ Path(label): Path,
State(state): State,
) -> impl IntoResponse {
- match dispatch(&state, &session_id, "screenshot", |rid| {
+ match dispatch(&state, &label, "screenshot", |rid| {
ServerMessage::ScreenshotRequest { request_id: rid }
- })
- .await
- {
- Ok(ClientMessage::ScreenshotResponse {
- image_base64,
- width,
- height,
- ..
- }) => (
+ }).await {
+ Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => (
StatusCode::OK,
- Json(serde_json::json!({
- "image_base64": image_base64,
- "width": width,
- "height": height,
- })),
- )
- .into_response(),
+ Json(serde_json::json!({ "image_base64": image_base64, "width": width, "height": height })),
+ ).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
- )
- .into_response(),
- Ok(_) => (
- StatusCode::BAD_GATEWAY,
- Json(serde_json::json!({ "error": "Unexpected response from client" })),
- )
- .into_response(),
+ ).into_response(),
+ Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
-/// POST /sessions/:id/exec
+/// POST /devices/:label/windows/:window_id/screenshot
+pub async fn window_screenshot(
+ Path((label, window_id)): Path<(String, u64)>,
+ State(state): State,
+) -> 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,
+ Query(params): Query>,
+ State(state): State,
+) -> 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)]
pub struct ExecBody {
pub command: String,
+ pub timeout_ms: Option,
}
pub async fn request_exec(
- Path(session_id): Path,
+ Path(label): Path,
State(state): State,
Json(body): Json,
) -> impl IntoResponse {
- match dispatch(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
+ let server_timeout = body.timeout_ms
+ .map(|ms| Duration::from_millis(ms + 5_000))
+ .unwrap_or(REQUEST_TIMEOUT);
+
+ match dispatch_with_timeout(&state, &label, "exec", |rid| ServerMessage::ExecRequest {
request_id: rid,
command: body.command.clone(),
- })
- .await
- {
- Ok(ClientMessage::ExecResponse {
- stdout,
- stderr,
- exit_code,
- ..
- }) => (
+ timeout_ms: body.timeout_ms,
+ }, server_timeout).await {
+ Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => (
StatusCode::OK,
- Json(serde_json::json!({
- "stdout": stdout,
- "stderr": stderr,
- "exit_code": exit_code,
- })),
- )
- .into_response(),
+ Json(serde_json::json!({ "stdout": stdout, "stderr": stderr, "exit_code": exit_code })),
+ ).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
- )
- .into_response(),
- Ok(_) => (
- StatusCode::BAD_GATEWAY,
- Json(serde_json::json!({ "error": "Unexpected response from client" })),
- )
- .into_response(),
+ ).into_response(),
+ Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
-/// POST /sessions/:id/click
-#[derive(Deserialize)]
-pub struct ClickBody {
- pub x: i32,
- pub y: i32,
- #[serde(default)]
- pub button: MouseButton,
-}
-
-pub async fn request_click(
- Path(session_id): Path,
- State(state): State,
- Json(body): Json,
-) -> impl IntoResponse {
- match dispatch(&state, &session_id, "click", |rid| ServerMessage::ClickRequest {
- request_id: rid,
- x: body.x,
- y: body.y,
- button: body.button.clone(),
- })
- .await
- {
- Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
- Err(e) => e.into_response(),
- }
-}
-
-/// POST /sessions/:id/type
-#[derive(Deserialize)]
-pub struct TypeBody {
- pub text: String,
-}
-
-pub async fn request_type(
- Path(session_id): Path,
- State(state): State,
- Json(body): Json,
-) -> impl IntoResponse {
- match dispatch(&state, &session_id, "type", |rid| ServerMessage::TypeRequest {
- request_id: rid,
- text: body.text.clone(),
- })
- .await
- {
- Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
- Err(e) => e.into_response(),
- }
-}
-
-/// GET /sessions/:id/windows
+/// GET /devices/:label/windows
pub async fn list_windows(
- Path(session_id): Path,
+ Path(label): Path,
State(state): State,
) -> impl IntoResponse {
- match dispatch(&state, &session_id, "list_windows", |rid| {
+ match dispatch(&state, &label, "list_windows", |rid| {
ServerMessage::ListWindowsRequest { request_id: rid }
- })
- .await
- {
+ }).await {
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "windows": windows })),
- )
- .into_response(),
+ ).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 from client" })),
- )
- .into_response(),
+ ).into_response(),
+ Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
-/// POST /sessions/:id/windows/minimize-all
+/// POST /devices/:label/windows/minimize-all
pub async fn minimize_all(
- Path(session_id): Path,
+ Path(label): Path,
State(state): State,
) -> impl IntoResponse {
- match dispatch(&state, &session_id, "minimize_all", |rid| {
+ match dispatch(&state, &label, "minimize_all", |rid| {
ServerMessage::MinimizeAllRequest { request_id: rid }
- })
- .await
- {
+ }).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
-/// POST /sessions/:id/windows/:window_id/focus
+/// POST /devices/:label/windows/:window_id/focus
pub async fn focus_window(
- Path((session_id, window_id)): Path<(String, u64)>,
+ Path((label, window_id)): Path<(String, u64)>,
State(state): State,
) -> impl IntoResponse {
- match dispatch(&state, &session_id, "focus_window", |rid| {
+ match dispatch(&state, &label, "focus_window", |rid| {
ServerMessage::FocusWindowRequest { request_id: rid, window_id }
- })
- .await
- {
+ }).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
-/// POST /sessions/:id/windows/:window_id/maximize
+/// POST /devices/:label/windows/:window_id/maximize
pub async fn maximize_and_focus(
- Path((session_id, window_id)): Path<(String, u64)>,
+ Path((label, window_id)): Path<(String, u64)>,
State(state): State,
) -> impl IntoResponse {
- match dispatch(&state, &session_id, "maximize_and_focus", |rid| {
+ match dispatch(&state, &label, "maximize_and_focus", |rid| {
ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
- })
- .await
- {
+ }).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
-/// POST /sessions/:id/label
-#[derive(Deserialize)]
-pub struct LabelBody {
- pub label: String,
+/// GET /version — server version (public, no auth)
+pub async fn server_version() -> impl IntoResponse {
+ Json(serde_json::json!({
+ "commit": env!("GIT_COMMIT"),
+ }))
}
-pub async fn set_label(
- Path(session_id): Path,
+/// GET /devices/:label/version — client version
+pub async fn client_version(
+ Path(label): Path,
State(state): State,
- Json(body): Json,
) -> impl IntoResponse {
- let id = match session_id.parse::() {
- Ok(id) => id,
- Err(_) => {
- return (
- StatusCode::BAD_REQUEST,
- Json(serde_json::json!({ "error": format!("Invalid session id: '{session_id}'") })),
- )
- .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()
+ match dispatch(&state, &label, "version", |rid| {
+ ServerMessage::VersionRequest { request_id: rid }
+ }).await {
+ Ok(ClientMessage::VersionResponse { version, commit, .. }) => (
+ StatusCode::OK,
+ Json(serde_json::json!({ "version": version, "commit": commit })),
+ ).into_response(),
+ Ok(ClientMessage::Error { message, .. }) => (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({ "error": message })),
+ ).into_response(),
+ Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
+ Err(e) => e.into_response(),
+ }
+}
+
+/// POST /devices/:label/upload
+#[derive(Deserialize)]
+pub struct UploadBody {
+ pub path: String,
+ pub content_base64: String,
+}
+
+pub async fn upload_file(
+ Path(label): Path,
+ State(state): State,
+ Json(body): Json,
+) -> 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,
+ State(state): State,
+ Query(query): Query,
+) -> 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)]
+ pub args: Vec,
+}
+
+pub async fn run_program(
+ Path(label): Path,
+ State(state): State,
+ Json(body): Json,
+) -> impl IntoResponse {
+ match dispatch(&state, &label, "run", |rid| ServerMessage::RunRequest {
+ request_id: rid,
+ program: body.program.clone(),
+ args: body.args.clone(),
+ }).await {
+ Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
+ Err(e) => e.into_response(),
+ }
+}
+
+/// GET /devices/:label/clipboard
+pub async fn clipboard_get(
+ Path(label): Path,
+ State(state): State,
+) -> 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)]
+pub struct ClipboardSetBody {
+ pub text: String,
+}
+
+pub async fn clipboard_set(
+ Path(label): Path,
+ State(state): State,
+ Json(body): Json,
+) -> impl IntoResponse {
+ match dispatch(&state, &label, "clipboard_set", |rid| {
+ ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
+ }).await {
+ Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
+ Err(e) => e.into_response(),
+ }
+}
+
+/// POST /relay/update — self-update the relay binary and restart the service
+pub async fn relay_update() -> impl IntoResponse {
+ tokio::spawn(async {
+ // Give the HTTP response time to be sent before we restart
+ tokio::time::sleep(Duration::from_millis(800)).await;
+
+ let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-relay-linux";
+ let bytes = match reqwest::get(url).await {
+ Ok(r) => match r.bytes().await {
+ Ok(b) => b,
+ Err(e) => {
+ error!("relay update: failed to read response body: {e}");
+ return;
+ }
+ },
+ Err(e) => {
+ error!("relay update: download failed: {e}");
+ return;
+ }
+ };
+
+ let exe = match std::env::current_exe() {
+ Ok(p) => p,
+ Err(e) => {
+ error!("relay update: current_exe: {e}");
+ return;
+ }
+ };
+
+ let tmp = exe.with_extension("new");
+ if let Err(e) = std::fs::write(&tmp, &bytes) {
+ error!("relay update: write tmp failed: {e}");
+ return;
+ }
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
+ }
+
+ if let Err(e) = std::fs::rename(&tmp, &exe) {
+ error!("relay update: rename failed: {e}");
+ return;
+ }
+
+ let _ = std::process::Command::new("systemctl")
+ .args(["restart", "helios-remote"])
+ .spawn();
+ });
+
+ (
+ axum::http::StatusCode::OK,
+ Json(serde_json::json!({ "ok": true, "message": "update triggered, relay restarting..." })),
+ )
+}
+
+/// POST /devices/:label/update — trigger client self-update
+pub async fn client_update(
+ Path(label): Path,
+ State(state): State,
+) -> impl IntoResponse {
+ match dispatch_with_timeout(&state, &label, "update", |rid| {
+ ServerMessage::UpdateRequest { request_id: rid }
+ }, Duration::from_secs(60)).await {
+ Ok(ClientMessage::UpdateResponse { success, message, .. }) => (
+ StatusCode::OK,
+ Json(serde_json::json!({ "success": success, "message": message })),
+ ).into_response(),
+ Ok(ClientMessage::Ack { .. }) => (
+ StatusCode::OK,
+ Json(serde_json::json!({ "success": true, "message": "update acknowledged" })),
+ ).into_response(),
+ Ok(ClientMessage::Error { message, .. }) => (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({ "success": false, "message": message })),
+ ).into_response(),
+ Ok(_) => (
+ StatusCode::OK,
+ Json(serde_json::json!({ "success": true, "message": "acknowledged" })),
+ ).into_response(),
+ Err(e) => e.into_response(),
+ }
+}
+
+/// POST /devices/:label/inform
+pub async fn inform_user(
+ Path(label): Path,
+ State(state): State,
+ Json(body): Json,
+) -> impl IntoResponse {
+ match dispatch(&state, &label, "inform", |rid| ServerMessage::InformRequest {
+ request_id: rid,
+ message: body.message.clone(),
+ title: body.title.clone(),
+ }).await {
+ Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
+ Err(e) => e.into_response(),
+ }
+}
+
+/// POST /devices/:label/prompt
+#[derive(Deserialize)]
+pub struct PromptBody {
+ pub message: String,
+ pub title: Option,
+}
+
+pub async fn prompt_user(
+ Path(label): Path,
+ State(state): State,
+ Json(body): Json,
+) -> impl IntoResponse {
+ match dispatch(&state, &label, "prompt", |rid| ServerMessage::PromptRequest {
+ request_id: rid,
+ message: body.message.clone(),
+ title: body.title.clone(),
+ }).await {
+ Ok(ClientMessage::PromptResponse { answer, .. }) => {
+ (StatusCode::OK, Json(serde_json::json!({ "ok": true, "answer": answer }))).into_response()
+ }
+ Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
+ Err(e) => e.into_response(),
}
}
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
index 9e88a6c..d60a143 100644
--- a/crates/server/src/main.rs
+++ b/crates/server/src/main.rs
@@ -30,6 +30,9 @@ async fn main() -> anyhow::Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
+ const GIT_COMMIT: &str = env!("GIT_COMMIT");
+ info!("helios-server ({GIT_COMMIT})");
+
let api_key = std::env::var("HELIOS_API_KEY")
.unwrap_or_else(|_| "dev-secret".to_string());
@@ -42,20 +45,30 @@ async fn main() -> anyhow::Result<()> {
};
let protected = Router::new()
- .route("/sessions", get(api::list_sessions))
- .route("/sessions/:id/screenshot", post(api::request_screenshot))
- .route("/sessions/:id/exec", post(api::request_exec))
- .route("/sessions/:id/click", post(api::request_click))
- .route("/sessions/:id/type", post(api::request_type))
- .route("/sessions/:id/label", post(api::set_label))
- .route("/sessions/:id/windows", get(api::list_windows))
- .route("/sessions/:id/windows/minimize-all", post(api::minimize_all))
- .route("/sessions/:id/windows/:window_id/focus", post(api::focus_window))
- .route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus))
+ .route("/devices", get(api::list_devices))
+ .route("/devices/:label/screenshot", post(api::request_screenshot))
+ .route("/devices/:label/exec", post(api::request_exec))
+ .route("/devices/:label/prompt", post(api::prompt_user))
+ .route("/devices/:label/inform", post(api::inform_user))
+ .route("/devices/:label/windows", get(api::list_windows))
+ .route("/devices/:label/windows/minimize-all", post(api::minimize_all))
+ .route("/devices/:label/logs", get(api::logs))
+ .route("/devices/:label/windows/:window_id/screenshot", post(api::window_screenshot))
+ .route("/devices/:label/windows/:window_id/focus", post(api::focus_window))
+ .route("/devices/:label/windows/:window_id/maximize", post(api::maximize_and_focus))
+ .route("/devices/:label/version", get(api::client_version))
+ .route("/devices/:label/upload", post(api::upload_file))
+ .route("/devices/:label/download", get(api::download_file))
+ .route("/devices/:label/run", post(api::run_program))
+ .route("/devices/:label/clipboard", get(api::clipboard_get))
+ .route("/devices/:label/clipboard", post(api::clipboard_set))
+ .route("/relay/update", post(api::relay_update))
+ .route("/devices/:label/update", post(api::client_update))
.layer(middleware::from_fn_with_state(state.clone(), require_api_key));
let app = Router::new()
.route("/ws", get(ws_handler::ws_upgrade))
+ .route("/version", get(api::server_version))
.merge(protected)
.with_state(state);
diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs
index c844373..150a374 100644
--- a/crates/server/src/session.rs
+++ b/crates/server/src/session.rs
@@ -4,11 +4,11 @@ use uuid::Uuid;
use serde::Serialize;
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)]
pub struct Session {
- pub id: Uuid,
- pub label: Option,
+ pub label: String,
/// Channel to send commands to the WS handler for this session
pub cmd_tx: mpsc::Sender,
}
@@ -16,22 +16,20 @@ pub struct Session {
/// Serializable view of a session for the REST API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
- pub id: Uuid,
- pub label: Option,
+ pub label: String,
}
impl From<&Session> for SessionInfo {
fn from(s: &Session) -> Self {
SessionInfo {
- id: s.id,
label: s.label.clone(),
}
}
}
pub struct SessionStore {
- /// Active sessions by ID
- sessions: DashMap,
+ /// Active sessions keyed by device label
+ sessions: DashMap,
/// Pending request callbacks by request_id
pending: DashMap>,
}
@@ -45,24 +43,15 @@ impl SessionStore {
}
pub fn insert(&self, session: Session) {
- self.sessions.insert(session.id, session);
+ self.sessions.insert(session.label.clone(), session);
}
- pub fn remove(&self, id: &Uuid) {
- self.sessions.remove(id);
+ pub fn remove(&self, label: &str) {
+ self.sessions.remove(label);
}
- pub fn get_cmd_tx(&self, id: &Uuid) -> Option> {
- 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 get_cmd_tx(&self, label: &str) -> Option> {
+ self.sessions.get(label).map(|s| s.cmd_tx.clone())
}
pub fn list(&self) -> Vec {
@@ -77,7 +66,6 @@ impl SessionStore {
}
/// Deliver a client response to the waiting request handler.
- /// Returns true if the request was found and resolved.
pub fn resolve_pending(&self, request_id: Uuid, msg: ClientMessage) -> bool {
if let Some((_, tx)) = self.pending.remove(&request_id) {
let _ = tx.send(msg);
diff --git a/crates/server/src/ws_handler.rs b/crates/server/src/ws_handler.rs
index a5ad66e..ce661c2 100644
--- a/crates/server/src/ws_handler.rs
+++ b/crates/server/src/ws_handler.rs
@@ -5,7 +5,6 @@ use axum::{
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
-use uuid::Uuid;
use tracing::{debug, error, info, warn};
use helios_common::protocol::{ClientMessage, ServerMessage};
@@ -19,32 +18,57 @@ pub async fn ws_upgrade(
}
async fn handle_socket(socket: WebSocket, state: AppState) {
- let session_id = Uuid::new_v4();
let (cmd_tx, mut cmd_rx) = mpsc::channel::(64);
+ let (mut ws_tx, mut ws_rx) = socket.split();
- // Register session (label filled in on Hello)
+ // Wait for the Hello message to get the device label
+ let label = loop {
+ match ws_rx.next().await {
+ Some(Ok(Message::Text(text))) => {
+ match serde_json::from_str::(&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 {
- id: session_id,
- label: None,
+ label: label.clone(),
cmd_tx,
};
state.sessions.insert(session);
- info!("Client connected: session={session_id}");
-
- let (mut ws_tx, mut ws_rx) = socket.split();
+ info!("Client connected: device={label}");
// Spawn task: forward server commands → WS
+ let label_clone = label.clone();
let send_task = tokio::spawn(async move {
while let Some(msg) = cmd_rx.recv().await {
match serde_json::to_string(&msg) {
Ok(json) => {
if let Err(e) = ws_tx.send(Message::Text(json.into())).await {
- error!("WS send error for session={session_id}: {e}");
+ error!("WS send error for device={label_clone}: {e}");
break;
}
}
Err(e) => {
- error!("Serialization error for session={session_id}: {e}");
+ error!("Serialization error for device={label_clone}: {e}");
}
}
}
@@ -55,45 +79,48 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
match result {
Ok(Message::Text(text)) => {
match serde_json::from_str::(&text) {
- Ok(msg) => handle_client_message(session_id, msg, &state).await,
+ Ok(msg) => handle_client_message(&label, msg, &state).await,
Err(e) => {
- warn!("Invalid JSON from session={session_id}: {e} | raw={text}");
+ warn!("Invalid JSON from device={label}: {e}");
}
}
}
Ok(Message::Close(_)) => {
- info!("Client disconnected gracefully: session={session_id}");
+ info!("Client disconnected gracefully: device={label}");
break;
}
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {}
Err(e) => {
- error!("WS receive error for session={session_id}: {e}");
+ error!("WS receive error for device={label}: {e}");
break;
}
}
}
send_task.abort();
- state.sessions.remove(&session_id);
- info!("Session cleaned up: session={session_id}");
+ state.sessions.remove(&label);
+ info!("Session cleaned up: device={label}");
}
-async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &AppState) {
+async fn handle_client_message(label: &str, msg: ClientMessage, state: &AppState) {
match &msg {
- ClientMessage::Hello { label } => {
- if let Some(lbl) = label {
- state.sessions.set_label(&session_id, lbl.clone());
- }
- debug!("Hello from session={session_id}, label={label:?}");
+ ClientMessage::Hello { .. } => {
+ debug!("Duplicate Hello from device={label}, ignoring");
}
ClientMessage::ScreenshotResponse { request_id, .. }
| ClientMessage::ExecResponse { request_id, .. }
| ClientMessage::ListWindowsResponse { request_id, .. }
+ | ClientMessage::VersionResponse { request_id, .. }
+ | ClientMessage::LogsResponse { request_id, .. }
+ | ClientMessage::DownloadResponse { request_id, .. }
+ | ClientMessage::ClipboardGetResponse { request_id, .. }
+ | ClientMessage::PromptResponse { request_id, .. }
+ | ClientMessage::UpdateResponse { request_id, .. }
| ClientMessage::Ack { request_id }
| ClientMessage::Error { request_id, .. } => {
let rid = *request_id;
if !state.sessions.resolve_pending(rid, msg) {
- warn!("No pending request for request_id={rid} (session={session_id})");
+ warn!("No pending request for request_id={rid} (device={label})");
}
}
}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index e95cb29..43cd888 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -1,5 +1,5 @@
# Helios Remote - Install Script
-# Downloads and starts the helios-remote Windows client
+# Downloads and starts the helios-remote Windows client.
#
# IMPORTANT: Windows Defender may block the executable.
# If the download or launch fails, disable real-time protection first (run as Admin):
@@ -9,11 +9,17 @@
$ErrorActionPreference = "Stop"
-$url = "https://github.com/agent-helios/helios-remote/releases/latest/download/helios-remote-client-windows.exe"
-$dest = "$env:TEMP\helios-remote.exe"
+$url = "https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe"
+$dest = "$env:USERPROFILE\Desktop\Helios Remote.exe"
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..."
-Start-Process -FilePath $dest -NoNewWindow
+Start-Process -FilePath $dest