Compare commits

...

101 commits

Author SHA1 Message Date
911011ac44
Remove CI/CD workflow and Dockerfile
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-windows-client (push) Has been cancelled
CI / deploy-relay (push) Has been cancelled
CI / build-cli (push) Has been cancelled
2026-03-28 15:16:04 +01:00
5c83dadee3
Trigger CI: fix registry token scopes
Some checks failed
Build & Push Docker Image / build (push) Failing after 9m54s
2026-03-28 15:02:03 +01:00
e3acd0b8f4
Fix: install docker CLI in build container
Some checks failed
Build & Push Docker Image / build (push) Failing after 9m55s
2026-03-28 14:47:39 +01:00
7b2ee7f616
Fix: mount docker socket in build containers
Some checks failed
Build & Push Docker Image / build (push) Failing after 9m48s
2026-03-28 14:29:34 +01:00
b2ff20208e
Trigger CI: fix network name services_git
Some checks failed
Build & Push Docker Image / build (push) Failing after 11m41s
2026-03-28 13:53:57 +01:00
7233989fdc
Trigger CI: fix container network
Some checks failed
Build & Push Docker Image / build (push) Failing after 0s
2026-03-28 13:52:12 +01:00
9583b231a0
Fix: trigger on master branch
Some checks failed
Build & Push Docker Image / build (push) Failing after 39s
2026-03-28 13:49:38 +01:00
863c14120a
Add CI/CD: multi-arch Docker build workflow 2026-03-28 13:48:39 +01:00
8f3615c5cf
chore: retrigger CI
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-windows-client (push) Has been cancelled
CI / build-cli (push) Has been cancelled
CI / deploy-relay (push) Has been cancelled
2026-03-06 15:49:34 +01:00
b9783cc5c8
chore: final end-to-end update test 2026-03-06 15:47:53 +01:00
9c429ce20e
docs: update README and SKILL.md - document update system, rename binaries 2026-03-06 15:24:14 +01:00
bd1835f5a3
feat: check version.json for latest available commit 2026-03-06 15:20:26 +01:00
e9bbbb8171
chore: final update test 2026-03-06 15:17:07 +01:00
840cecfce5
fix: use rustls for CLI to fix aarch64 cross-compile 2026-03-06 15:10:17 +01:00
6224c9a1e0
feat: build aarch64 CLI + CLI self-update on all platforms 2026-03-06 15:06:20 +01:00
e15834c179
fix: CI no longer auto-restarts relay; update only via CLI 2026-03-06 14:59:58 +01:00
bd9b92c861
fix: release instance lock before spawning updated client 2026-03-06 14:30:49 +01:00
a3100a872b
fix: delete .old.exe on startup + use cmd start for new window 2026-03-06 14:30:01 +01:00
dbdafcfbd1
chore: trigger build for update test 2026-03-06 14:26:22 +01:00
cf6c1a076f
fix: spawn new console window on Windows after self-update 2026-03-06 14:24:22 +01:00
8e7b465538
fix: client auto-restart after update + delete old binary 2026-03-06 14:07:19 +01:00
33e1e4d550
fix: skip CLI self-update on non-x86_64 (ARM/Pi) 2026-03-06 14:03:29 +01:00
b05517eadf
docs: minor readme tweak 2026-03-06 12:33:26 +01:00
82c0066bd1
fix: use rustls for relay to fix x86_64 cross-compile 2026-03-06 12:32:28 +01:00
6345209538
feat: add update command to CLI, relay, and client 2026-03-06 12:16:10 +01:00
835d20f734
refactor: rename binaries to helios-remote-{client,relay,cli} 2026-03-06 11:53:00 +01:00
924be0540e
chore: remove compiled binary, add to gitignore 2026-03-06 11:41:42 +01:00
5124856b72
fix: embed icon in Windows exe via direct resource.o linker arg 2026-03-06 11:39:42 +01:00
4353045b1a
fix: replace single-size ICO with proper multi-size icon (16/32/48/256px) 2026-03-06 03:56:42 +01:00
716d10e87c
fix: improve winres icon embedding, log success/failure, auto-detect windres path 2026-03-06 03:51:02 +01:00
00380e07f3
feat: add app icon (logo.png for README, icon.ico embedded in Windows exe) 2026-03-06 03:40:32 +01:00
89ab74406f
fix: display::cmd_done for inform, remove unused imports, align README 2026-03-06 03:17:54 +01:00
5a15126a6a
docs: fix README prompt→inform, update logs default to 20 2026-03-06 03:14:49 +01:00
af0b6b5ddb
feat: replace prompt with inform (fire-and-forget), logs default 20 lines 2026-03-06 03:13:42 +01:00
d2f77f8054
fix: rename binary from helios to remote 2026-03-06 03:09:23 +01:00
98b6fabef6
feat: rewrite remote.py as Rust CLI binary (crates/cli) 2026-03-06 03:02:22 +01:00
ba3b365f4e
docs: simplify README, remove REST API examples and dev section, polish SKILL.md 2026-03-06 02:55:51 +01:00
3c7f970d4f
fix: filter Program Manager title, msedgewebview2 process (embedded components) 2026-03-06 02:45:38 +01:00
0cf6ab15d9
fix: filter cloaked windows (DwmGetWindowAttribute DWMWA_CLOAKED) to exclude background UWP apps 2026-03-06 02:41:12 +01:00
073ac283aa
fix: filter applicationframehost (UWP container, always duplicate) 2026-03-06 02:39:08 +01:00
bc8ffa191d
simplify: only IsWindowVisible + non-empty title, no complex filters 2026-03-06 02:33:18 +01:00
15e177087b
fix: PWSTR type for QueryFullProcessImageNameW 2026-03-06 02:25:12 +01:00
cd1388a02b
fix: fallback to title when process name unavailable, dont skip windows 2026-03-06 02:23:10 +01:00
948f7de3a9
fix: loosen ghost window filter, only block Program Manager 2026-03-06 02:19:27 +01:00
8a873e4923
fix: compile errors - GetWindowLongA, Win32_System_ProcessStatus feature 2026-03-06 02:11:20 +01:00
450604bbbd
fix: process-name window labels, ghost window filtering, self-restart after setup 2026-03-06 02:06:20 +01:00
0b4a6de8ae
refactor: enforce device labels, unify screenshot, remove deprecated commands, session-id-less design
- Device labels: lowercase, no whitespace, only a-z 0-9 - _ (enforced at config time)
- Session IDs removed: device label is the sole identifier
- Routes changed: /sessions/:id → /devices/:label
- Removed commands: click, type, find-window, wait-for-window, label, old version, server-version
- Renamed: status → version (compares relay/remote.py/client commits)
- Unified screenshot: takes 'screen' or a window label as argument
- Windows listed with human-readable labels (same format as device labels)
- Single instance enforcement via PID lock file
- Removed input.rs (click/type functionality)
- All docs and code in English
- Protocol: Hello.label is now required (String, not Option<String>)
- Client auto-migrates invalid labels on startup
2026-03-06 01:55:28 +01:00
5fd01a423d
fix: exec errors show first stderr line instead of 'exit 1' 2026-03-05 21:31:16 +01:00
996f74b24f
feat: prompt returns answer to caller + commit on own header line 2026-03-05 21:22:18 +01:00
c5ef006414
fix: header values all dimmed (no admin, device, session) 2026-03-05 21:12:16 +01:00
1ec82cd177
feat: prompt reads from stdin in CLI (🌀/🎤 table rows, purple answer) 2026-03-05 21:08:14 +01:00
9589958cb1
fix: banner alignment + rename commands (execute, connect, list windows, set/get clipboard, etc) 2026-03-05 20:57:22 +01:00
20cae0b717
fix: header alignment, 🪪 session, commit-only version, clipboard shows text, no extra blank lines 2026-03-05 20:45:49 +01:00
05a63fe911
fix: truncate before colorize (no dangling ANSI), emoji cleanup (#, 📁) 2026-03-05 20:35:07 +01:00
03d80067a8
feat: structured startup header with privileges, device, session 2026-03-05 20:22:58 +01:00
b37eec24bc
fix: emoji column alignment + remove '· exit 0' from success output
- Add unicode-width crate, emoji_cell() pads 1-wide symbols (ℹ, ☀) to 2 cols
- All action/status cells now occupy exactly 2 terminal display columns
- exec success: show only first output line, no trailing '· exit 0'
2026-03-05 20:10:28 +01:00
8f26d2fbf3
ci: fix VPS deploy key 2026-03-05 20:04:15 +01:00
72f19af12b
feat: all output lines in table format incl. connecting/session rows 2026-03-05 19:59:18 +01:00
959a00ff8a
refactor: table-style display with live spinner (🔄/)
- Remove \t-based alignment (fixes emoji spacing inconsistencies)
- New display.rs module: table rows with dynamic terminal-width columns
- Columns: action_emoji | name (14ch) | payload (55%) | status_emoji | result (45%)
- cmd_start() prints 🔄 spinner, cmd_done() overwrites line in-place via ANSI cursor-up
- Payload and result truncated to column width with ellipsis
- Consistent 2-space gaps after every emoji (no tab stops)
- Add terminal_size crate for dynamic width (fallback: 120)
2026-03-05 19:47:39 +01:00
7c0341a5f3
docs: vollständige Befehlsdokumentation in SKILL.md und README 2026-03-04 14:07:51 +01:00
fc9a7e2ec2
fix: handle LogsResponse in ws_handler match 2026-03-04 11:59:54 +01:00
9f933b39e7
feat: move skill files to repo root 2026-03-03 17:32:26 +01:00
db3fa9f416
feat: file logging on client, logs command to fetch last N lines 2026-03-03 17:09:23 +01:00
23bbb5b603
fix(client): use instead of ✗ for errors 2026-03-03 17:02:20 +01:00
3aa78756a5
fix(client): use \t for consistent emoji alignment everywhere 2026-03-03 17:01:58 +01:00
e42ad48235
fix(remote.py): window_id as string in argparse for focus/maximize 2026-03-03 17:00:06 +01:00
0439c70a27
feat(remote.py): show own git commit in status command 2026-03-03 16:56:37 +01:00
6643a33570
feat: status command (relay+client sync), remove version from banner 2026-03-03 16:55:11 +01:00
d114c813fb
fix(client): remove → arrow before globe, use for ok log 2026-03-03 16:53:02 +01:00
9285dbbd49
fix(client): window screenshot via crop instead of PrintWindow, fix SW_MINIMIZE 2026-03-03 16:49:57 +01:00
92d3907ec7
fix(client): PrintWindow in Gdi not WindowsAndMessaging, clean up unused imports 2026-03-03 16:44:46 +01:00
efc9cab2c3
feat: window screenshot (PrintWindow), name-based window resolution 2026-03-03 16:39:23 +01:00
27b1ffc55b
fix(client): safe Unicode truncation via trunc(), fixes panic on non-ASCII chars 2026-03-03 16:35:20 +01:00
1823b6021a
fix(client): globe emoji before Connecting 2026-03-03 16:29:29 +01:00
314ebab5c9
fix(client): per-command emojis, remove separator line, fix admin line indent 2026-03-03 16:28:26 +01:00
20e97b932b
fix: test missing timeout_ms field, unused title warning 2026-03-03 16:20:23 +01:00
b86717f7dc
ci: auto-deploy server + client to VPS on every push to master 2026-03-03 16:16:46 +01:00
e00270550d
feat: wait-for-window command; add click guideline to SKILL.md 2026-03-03 16:12:32 +01:00
7ddaf5ddfe
docs: update README + SKILL.md with prompt, timeout, all current commands 2026-03-03 16:08:02 +01:00
537ed95a3c
feat: configurable exec timeout per request (--timeout flag, default 30s) 2026-03-03 16:05:29 +01:00
1c0af1693b
fix(client): remove dash separator before admin status in banner 2026-03-03 16:01:18 +01:00
4af2680078
fix(client): show ✗ on non-zero exit code instead of ✓ 2026-03-03 15:57:04 +01:00
72cf15a6e3
fix(client): enable ANSI processing explicitly for admin/elevated terminals 2026-03-03 15:49:55 +01:00
e0edf60461
feat: prompt command (MessageBox), admin status in banner 2026-03-03 15:44:27 +01:00
fdd2124da8
feat: add OpenClaw skill (remote.py + SKILL.md + config.env.example) 2026-03-03 15:39:33 +01:00
4bad20a24c
fix(client): filter invisible windows server-side, log shows real count 2026-03-03 15:35:58 +01:00
e942bbad58
fix(client): force-foreground with fake Alt key, filter visible-only windows 2026-03-03 15:29:14 +01:00
672676d3d7
feat: find-window, run, clipboard, label-routing, persistent session-id, exe icon 2026-03-03 15:19:54 +01:00
ef4ca0ccbb
fix(client): truncate long commands and output in log lines (max 60 chars) 2026-03-03 15:06:30 +01:00
ccf585f801
fix(client): use powershell -NoProfile as executor to prevent $PROFILE clear 2026-03-03 14:59:09 +01:00
0e8f2b11e8
fix(client): strip shell.rs to bare minimum - no chcp, no wrapper, just cmd /D /C 2026-03-03 14:58:11 +01:00
07d758a631
fix(client): use cmd /D to disable AutoRun, prevents terminal clear on each command 2026-03-03 14:51:31 +01:00
18e844033a
fix(client): remove broken catch_unwind, tokio task isolation is sufficient 2026-03-03 14:43:55 +01:00
a43c5c3197
fix(client): per-process execution, UTF-8 lossy decode, panic isolation 2026-03-03 14:40:21 +01:00
fe1b385776
fix: use curl.exe for download in install.ps1 (more reliable on Windows 10+) 2026-03-03 14:30:53 +01:00
f7d29a98d3
feat: commit hash in banner, version command, file upload/download 2026-03-03 14:29:22 +01:00
cb86894369
fix: use WebClient instead of Invoke-WebRequest in install.ps1 (TLS compat) 2026-03-03 14:27:38 +01:00
346386db99
fix(client): use contains() for sentinel detection to fix Windows timeout
On Windows, cmd.exe echoes the full prompt before the command output,
so the sentinel line looks like:

  C:\Users\Moritz\Desktop>__HELIOS_DONE__0\r\n

starts_with(SENTINEL) never matched. Switching to contains() + finding
the sentinel position fixes detection on both Windows and Unix.
2026-03-03 14:17:56 +01:00
9e6878a93f
fix: flush stdout prompts in first-run config wizard
The prompt_config() function used print!() without flushing stdout,
causing all prompts to be buffered and invisible to the user. The
program appeared to hang silently after 'No config found — first-time
setup'.

Changes:
- Add std::io::stdout().flush().unwrap() after each print!() prompt
- Style prompts with cyan '→' prefix to match CLI conventions
- Show default values in square brackets for relay URL and label
- Use hostname() as default label (was previously Optional/None)
- Add blank line after prompts before '✓ Config saved'
2026-03-03 14:11:58 +01:00
e32e09996b
feat(client): modern CLI output + Windows exe icon
- Replace timestamp-based log macros with colored CLI output system
  using the `colored` crate
- Banner on startup: ☀ HELIOS REMOTE + separator line
- Colored prefixes: cyan → (status), green ✓ (ok), red ✗ (error),
  yellow  (incoming commands)
- Session info on connect: ✓ Connected · <label> · Session <id>
- No timestamps, no [CMD]/[OK]/[ERR] prefixes
- Suppress tracing output by default (RUST_LOG=off unless set)
- Add build.rs to embed logo.ico as Windows resource via winres
- Add winres as build-dependency in client Cargo.toml
2026-03-03 14:02:17 +01:00
1d019fa2b4
client: verbose CLI output, TOML config in APPDATA, desktop install
- install.ps1: place exe on Desktop instead of TEMP, start with visible window
- main.rs: banner on startup, [CMD]/[OK]/[ERR] prefixed logs with HH:MM:SS timestamps
- Config: switch from JSON to TOML (config.toml in %APPDATA%\helios-remote\)
- First-run wizard prompts for Relay URL + API Key (relay_code -> api_key)
- Add chrono + toml deps to Cargo.toml
2026-03-03 13:55:22 +01:00
31 changed files with 2906 additions and 852 deletions

6
.cargo/config.toml Normal file
View 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"

View file

@ -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
View file

@ -3,3 +3,4 @@ Cargo.lock
**/*.rs.bk
.env
*.pdb
remote

View file

@ -3,5 +3,6 @@ members = [
"crates/common",
"crates/server",
"crates/client",
"crates/cli",
]
resolver = "2"

134
README.md
View file

@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Before After
Before After

2
config.env Normal file
View file

@ -0,0 +1,2 @@
HELIOS_REMOTE_URL=https://remote.agent-helios.me
HELIOS_REMOTE_API_KEY=SqY8jLUrZugp6N4UhVPq7KDT0CeU2P7

2
config.env.example Normal file
View 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
View 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
View 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
View 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(""));
}
}
}

View file

@ -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",
] }

View file

@ -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
View 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}");
}
}
}
}

View 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);
}

View file

@ -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())
}

View 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")
}

View file

@ -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() }
}
}
}

View file

@ -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

View file

@ -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())),
}
}

View file

@ -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(())
}

View file

@ -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"),
}
}
}

View file

@ -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
View 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");
}

View file

@ -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(),
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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})");
}
}
}

View file

@ -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