diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..3b25b33 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[target.x86_64-unknown-linux-gnu] +linker = "x86_64-linux-gnu-gcc" + +[target.x86_64-pc-windows-gnu] +linker = "x86_64-w64-mingw32-gcc" +ar = "x86_64-w64-mingw32-ar" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f88b336..bdcd514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,8 @@ jobs: with: targets: x86_64-pc-windows-gnu - - name: Install MinGW cross-compiler - run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 + - name: Install MinGW cross-compiler and tools + run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools - name: Cache dependencies uses: Swatinem/rust-cache@v2 @@ -47,7 +47,7 @@ jobs: - name: Build Windows client (cross-compile) run: | - cargo build --release --package helios-client --target x86_64-pc-windows-gnu + cargo build --release --package helios-remote-client --target x86_64-pc-windows-gnu env: CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc @@ -55,12 +55,12 @@ jobs: uses: actions/upload-artifact@v4 with: name: helios-remote-client-windows - path: target/x86_64-pc-windows-gnu/release/helios-client.exe + path: target/x86_64-pc-windows-gnu/release/helios-remote-client.exe if-no-files-found: error - name: Rename exe for release if: github.ref == 'refs/heads/master' - run: cp target/x86_64-pc-windows-gnu/release/helios-client.exe helios-remote-client-windows.exe + run: cp target/x86_64-pc-windows-gnu/release/helios-remote-client.exe helios-remote-client-windows.exe - name: Publish rolling release (latest) if: github.ref == 'refs/heads/master' @@ -73,3 +73,126 @@ jobs: files: helios-remote-client-windows.exe env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy client to VPS + if: github.ref == 'refs/heads/master' + env: + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts + scp -i ~/.ssh/deploy_key helios-remote-client-windows.exe \ + root@46.225.185.232:/var/www/helios-remote/helios-remote-client-windows.exe + + deploy-relay: + runs-on: ubuntu-latest + needs: build-and-test + if: github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + x86_64-linux target + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Install cross-linker + run: sudo apt-get update && sudo apt-get install -y gcc-x86-64-linux-gnu + + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + with: + key: linux-x86_64 + + - name: Build relay (x86_64 Linux) + run: cargo build --release --package helios-remote-relay --target x86_64-unknown-linux-gnu + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc + + - name: Deploy relay to VPS + env: + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts + # Only publish to download URL — relay updates itself when triggered by CLI + scp -i ~/.ssh/deploy_key \ + target/x86_64-unknown-linux-gnu/release/helios-remote-relay \ + root@46.225.185.232:/var/www/helios-remote/helios-remote-relay-linux + # Write version.json so CLI knows what's available + echo "{\"commit\":\"$(git rev-parse --short HEAD)\"}" > version.json + scp -i ~/.ssh/deploy_key version.json \ + root@46.225.185.232:/var/www/helios-remote/version.json + + build-cli: + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (stable) + targets + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu,x86_64-pc-windows-gnu,aarch64-unknown-linux-gnu + + - name: Install cross-compilers + run: sudo apt-get update && sudo apt-get install -y gcc-x86-64-linux-gnu gcc-mingw-w64-x86-64 mingw-w64-tools gcc-aarch64-linux-gnu + + - name: Cache dependencies + uses: Swatinem/rust-cache@v2 + with: + key: cli + + - name: Build CLI (Linux x86_64) + run: cargo build --release --package helios-remote-cli --target x86_64-unknown-linux-gnu + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-gcc + + - name: Build CLI (Linux aarch64) + run: cargo build --release --package helios-remote-cli --target aarch64-unknown-linux-gnu + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Build CLI (Windows x86_64) + run: cargo build --release --package helios-remote-cli --target x86_64-pc-windows-gnu + env: + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + + - name: Upload Linux CLI artifact + uses: actions/upload-artifact@v4 + with: + name: helios-remote-cli-linux + path: target/x86_64-unknown-linux-gnu/release/helios-remote-cli + if-no-files-found: error + + - name: Upload Windows CLI artifact + uses: actions/upload-artifact@v4 + with: + name: helios-remote-cli-windows + path: target/x86_64-pc-windows-gnu/release/helios-remote-cli.exe + if-no-files-found: error + + - name: Deploy CLI to VPS + if: github.ref == 'refs/heads/master' + env: + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H 46.225.185.232 >> ~/.ssh/known_hosts + scp -i ~/.ssh/deploy_key \ + target/x86_64-unknown-linux-gnu/release/helios-remote-cli \ + root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-linux + scp -i ~/.ssh/deploy_key \ + target/aarch64-unknown-linux-gnu/release/helios-remote-cli \ + root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-linux-aarch64 + scp -i ~/.ssh/deploy_key \ + target/x86_64-pc-windows-gnu/release/helios-remote-cli.exe \ + root@46.225.185.232:/var/www/helios-remote/helios-remote-cli-windows.exe diff --git a/.gitignore b/.gitignore index 6e65eaf..5b6f0e1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ Cargo.lock **/*.rs.bk .env *.pdb +remote diff --git a/Cargo.toml b/Cargo.toml index 40cb1dd..816c174 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,5 +3,6 @@ members = [ "crates/common", "crates/server", "crates/client", + "crates/cli", ] resolver = "2" diff --git a/README.md b/README.md index a1e0f03..c0f4c22 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ helios-remote logo

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