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

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