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