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