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: with:
targets: x86_64-pc-windows-gnu targets: x86_64-pc-windows-gnu
- name: Install MinGW cross-compiler - name: Install MinGW cross-compiler and tools
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools
- name: Cache dependencies - name: Cache dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@ -47,7 +47,7 @@ jobs:
- name: Build Windows client (cross-compile) - name: Build Windows client (cross-compile)
run: | 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: env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
@ -55,12 +55,12 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: helios-remote-client-windows 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 if-no-files-found: error
- name: Rename exe for release - name: Rename exe for release
if: github.ref == 'refs/heads/master' 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) - name: Publish rolling release (latest)
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
@ -73,3 +73,126 @@ jobs:
files: helios-remote-client-windows.exe files: helios-remote-client-windows.exe
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 **/*.rs.bk
.env .env
*.pdb *.pdb
remote

View file

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

134
README.md
View file

@ -4,7 +4,7 @@
<img src="assets/logo.png" width="150" alt="helios-remote logo" /> <img src="assets/logo.png" width="150" alt="helios-remote logo" />
</p> </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 ## Quick Connect
@ -26,109 +26,91 @@ irm https://raw.githubusercontent.com/agent-helios/helios-remote/master/scripts/
--- ---
## Architecture ## How It Works
```
helios-remote/
├── crates/
│ ├── common/ # Shared protocol types, WebSocket message definitions
│ ├── server/ # Relay server (REST API + WebSocket hub)
│ └── client/ # Windows client — Phase 2 (stub only)
├── Cargo.toml # Workspace root
└── README.md
```
### How It Works
``` ```
AI Agent AI Agent
│ REST API (X-Api-Key)
▼ helios-remote-cli
helios-server ──WebSocket── helios-client (Windows) helios-remote-relay ──WebSocket── helios-remote-client (Windows)
│ │
POST /sessions/:id/screenshot │ Captures screen → base64 PNG
POST /sessions/:id/exec │ Runs command in persistent shell
POST /sessions/:id/click │ Simulates mouse click
POST /sessions/:id/type │ Types text
``` ```
1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` message. 1. The **Windows client** connects to the relay server via WebSocket and registers with its device label.
2. The **AI agent** calls the REST API to issue commands. 2. The **AI agent** uses `helios` to issue commands — screenshots, shell commands, window management, file transfers.
3. The relay server forwards commands to the correct client session and streams back responses. 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 | ```bash
|---|---|---| remote devices # list connected devices
| `GET` | `/sessions` | List all connected clients | remote screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
| `POST` | `/sessions/:id/screenshot` | Request a screenshot (returns base64 PNG) | remote screenshot <device> <window_label> # screenshot a specific window
| `POST` | `/sessions/:id/exec` | Execute a shell command | remote exec <device> <command...> # run shell command (PowerShell)
| `POST` | `/sessions/:id/click` | Simulate a mouse click | remote exec <device> --timeout 600 <command...> # with custom timeout (seconds)
| `POST` | `/sessions/:id/type` | Type text | remote windows <device> # list visible windows
| `POST` | `/sessions/:id/label` | Rename a session | 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 ```bash
HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server
``` ```
Environment variables:
| Variable | Default | Description | | 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 | | `HELIOS_BIND` | `0.0.0.0:3000` | Listen address |
| `RUST_LOG` | `helios_server=debug` | Log level | | `RUST_LOG` | `helios_server=debug` | Log level |
### Example API Usage ---
```bash ## Downloads
# List sessions
curl -H "X-Api-Key: your-secret-key" http://localhost:3000/sessions
# Take a screenshot Pre-built binaries are available at:
curl -s -X POST -H "X-Api-Key: your-secret-key" \
http://localhost:3000/sessions/<session-id>/screenshot
# Run a command | Binary | Platform | Link |
curl -s -X POST -H "X-Api-Key: your-secret-key" \ |---|---|---|
-H "Content-Type: application/json" \ | `helios-remote-client` | Windows | [helios-remote-client-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe) |
-d '{"command": "whoami"}' \ | `helios-remote-cli` | Linux | [helios-remote-cli-linux](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux) |
http://localhost:3000/sessions/<session-id>/exec | `helios-remote-cli` | Windows | [helios-remote-cli-windows.exe](https://agent-helios.me/downloads/helios-remote/helios-remote-cli-windows.exe) |
# Click at coordinates The relay server (`helios-remote-relay`) runs on the VPS and is not distributed.
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
```
## 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 ## License
MIT 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] [package]
name = "helios-client" name = "helios-remote-client"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
name = "helios-client" name = "helios-remote-client"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
@ -13,13 +13,24 @@ tokio-tungstenite = { version = "0.21", features = ["connect", "native-tls"] }
native-tls = { version = "0.2", features = [] } native-tls = { version = "0.2", features = [] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8"
chrono = "0.4"
helios-common = { path = "../common" } helios-common = { path = "../common" }
uuid = { version = "1", features = ["v4"] }
dirs = "5" dirs = "5"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22" base64 = "0.22"
png = "0.17" png = "0.17"
futures-util = "0.3" 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] [target.'cfg(windows)'.dependencies]
windows = { version = "0.54", features = [ windows = { version = "0.54", features = [
@ -28,4 +39,8 @@ windows = { version = "0.54", features = [
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_UI_WindowsAndMessaging", "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://`) - Full-screen and per-window screenshots
- Sends a `Hello` message on connect with an optional display label - Shell command execution (persistent PowerShell session)
- Handles incoming `ServerMessage` commands: - Window management (list, focus, maximize, minimize)
- `ScreenshotRequest` → captures the primary display (Windows GDI or `windows-capture`) and responds with base64 PNG - File upload/download
- `ExecRequest` → runs a shell command in a persistent `cmd.exe` / PowerShell session and returns stdout/stderr/exit-code - Clipboard get/set
- `ClickRequest` → simulates a mouse click via `SendInput` Win32 API - Program launch (fire-and-forget)
- `TypeRequest` → types text via `SendInput` (virtual key events) - User prompts (MessageBox)
- Persistent shell session so `cd C:\Users` persists across `exec` calls - Single instance enforcement (PID lock file)
- Auto-reconnect with exponential backoff
- Configurable via environment variables or a `client.toml` config file
## Planned Tech Stack ## Configuration
| Crate | Purpose | On first run, the client prompts for:
|---|---| - **Relay URL** (default: `wss://remote.agent-helios.me/ws`)
| `tokio` | Async runtime | - **API Key**
| `tokio-tungstenite` | WebSocket client | - **Device label** — must be lowercase, no whitespace, only `a-z 0-9 - _`
| `serde_json` | Protocol serialization |
| `windows` / `winapi` | Screen capture, mouse/keyboard input |
| `base64` | PNG encoding for screenshots |
## 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::sync::Arc;
use std::time::Duration; use std::time::Duration;
use colored::Colorize;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use native_tls::TlsConnector; use native_tls::TlsConnector;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector}; use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Connector};
use tracing::{error, info, warn};
use base64::Engine;
use helios_common::{ClientMessage, ServerMessage}; 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 shell;
mod screenshot; mod screenshot;
mod input;
mod windows_mgmt; 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 { struct Config {
relay_url: String, relay_url: String,
relay_code: String, api_key: String,
label: Option<String>, label: String,
} }
impl Config { impl Config {
@ -28,82 +139,148 @@ impl Config {
let base = dirs::config_dir() let base = dirs::config_dir()
.or_else(|| dirs::home_dir()) .or_else(|| dirs::home_dir())
.unwrap_or_else(|| PathBuf::from(".")); .unwrap_or_else(|| PathBuf::from("."));
base.join("helios-remote").join("config.json") base.join("helios-remote").join("config.toml")
} }
fn load() -> Option<Self> { fn load() -> Option<Self> {
let path = Self::config_path(); let path = Self::config_path();
let data = std::fs::read_to_string(&path).ok()?; 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<()> { fn save(&self) -> std::io::Result<()> {
let path = Self::config_path(); let path = Self::config_path();
std::fs::create_dir_all(path.parent().unwrap())?; 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)?; std::fs::write(&path, data)?;
Ok(()) Ok(())
} }
} }
fn prompt_config() -> Config { fn prompt_config() -> Config {
use std::io::Write;
let relay_url = { 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(); let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap(); std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim(); let trimmed = input.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
"wss://remote.agent-helios.me/ws".to_string() default.to_string()
} else { } else {
trimmed.to_string() trimmed.to_string()
} }
}; };
let relay_code = { let api_key = {
println!("Enter relay code: "); print!(" {} API Key: ", "".cyan().bold());
std::io::stdout().flush().unwrap();
let mut input = String::new(); let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap(); std::io::stdin().read_line(&mut input).unwrap();
input.trim().to_string() input.trim().to_string()
}; };
let label = { let label = {
println!("Label for this machine (optional, press Enter to skip): "); let default_label = sanitize_label(&hostname());
let mut input = String::new(); loop {
std::io::stdin().read_line(&mut input).unwrap(); print!(" {} Device label [{}]: ", "".cyan().bold(), default_label);
let trimmed = input.trim().to_string(); std::io::stdout().flush().unwrap();
if trimmed.is_empty() { None } else { Some(trimmed) } 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] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt() #[cfg(windows)]
.with_env_filter( enable_ansi();
std::env::var("RUST_LOG") logger::init();
.unwrap_or_else(|_| "helios_client=info".to_string()),
) if std::env::var("RUST_LOG").is_err() {
.init(); 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 // Load or prompt for config
let config = match Config::load() { let config = match Config::load() {
Some(c) => { Some(c) => {
info!("Loaded config from {:?}", Config::config_path()); // Validate existing label
c 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 => { None => {
info!("No config found — prompting for setup"); display::info_line("", "setup:", "No config found — first-time setup");
println!();
let c = prompt_config(); let c = prompt_config();
println!();
if let Err(e) = c.save() { if let Err(e) = c.save() {
error!("Failed to save config: {e}"); display::err("", &format!("Failed to save config: {e}"));
} else { } 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 config = Arc::new(config);
let shell = Arc::new(Mutex::new(shell::PersistentShell::new())); let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
@ -112,43 +289,49 @@ async fn main() {
const MAX_BACKOFF: Duration = Duration::from_secs(30); const MAX_BACKOFF: Duration = Duration::from_secs(30);
loop { loop {
info!("Connecting to {}", config.relay_url); let host = config.relay_url
// Build TLS connector - accepts self-signed certs for internal CA (Caddy tls internal) .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() let tls_connector = TlsConnector::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.build() .build()
.expect("TLS connector build failed"); .expect("TLS connector build failed");
let connector = Connector::NativeTls(tls_connector); let connector = Connector::NativeTls(tls_connector);
match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await { match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await {
Ok((ws_stream, _)) => { Ok((ws_stream, _)) => {
info!("Connected!"); display::cmd_done("🌐", "connect", host, true, "connected");
backoff = Duration::from_secs(1); // reset on success backoff = Duration::from_secs(1);
let (mut write, mut read) = ws_stream.split(); let (mut write, mut read) = ws_stream.split();
// Send Hello // Send Hello with device label
let hello = ClientMessage::Hello { let hello = ClientMessage::Hello {
label: config.label.clone(), label: label.clone(),
}; };
let hello_json = serde_json::to_string(&hello).unwrap(); let hello_json = serde_json::to_string(&hello).unwrap();
if let Err(e) = write.send(Message::Text(hello_json)).await { 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; tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF); backoff = (backoff * 2).min(MAX_BACKOFF);
continue; continue;
} }
// Shared write half
let write = Arc::new(Mutex::new(write)); let write = Arc::new(Mutex::new(write));
// Process messages
while let Some(msg_result) = read.next().await { while let Some(msg_result) = read.next().await {
match msg_result { match msg_result {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
let server_msg: ServerMessage = match serde_json::from_str(&text) { let server_msg: ServerMessage = match serde_json::from_str(&text) {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
warn!("Failed to parse server message: {e}\nRaw: {text}"); display::err("", &format!("Failed to parse server message: {e}"));
continue; continue;
} }
}; };
@ -158,10 +341,16 @@ async fn main() {
tokio::spawn(async move { tokio::spawn(async move {
let response = handle_message(server_msg, shell_clone).await; 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; let mut w = write_clone.lock().await;
if let Err(e) = w.send(Message::Text(json)).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; let _ = w.send(Message::Pong(data)).await;
} }
Ok(Message::Close(_)) => { Ok(Message::Close(_)) => {
info!("Server closed connection"); display::cmd_start("🌐", "connect", host);
display::cmd_done("🌐", "connect", host, false, "connection lost");
break; break;
} }
Err(e) => { Err(e) => {
error!("WebSocket error: {e}"); display::cmd_done("🌐", "connect", host, false, &format!("lost: {e}"));
break; break;
} }
_ => {} _ => {}
} }
} }
warn!("Disconnected. Reconnecting in {:?}...", backoff);
} }
Err(e) => { 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( async fn handle_message(
msg: ServerMessage, msg: ServerMessage,
shell: Arc<Mutex<shell::PersistentShell>>, shell: Arc<Mutex<shell::PersistentShell>>,
) -> ClientMessage { ) -> ClientMessage {
match msg { 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 } => { ServerMessage::ScreenshotRequest { request_id } => {
display::cmd_start("📷", "screenshot", "screen");
match screenshot::take_screenshot() { match screenshot::take_screenshot() {
Ok((image_base64, width, height)) => ClientMessage::ScreenshotResponse { Ok((image_base64, width, height)) => {
request_id, display::cmd_done("📷", "screenshot", "screen", true, &format!("{width}×{height}"));
image_base64, ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
width, }
height,
},
Err(e) => { Err(e) => {
error!("Screenshot failed: {e}"); display::cmd_done("📷", "screenshot", "screen", false, &format!("{e}"));
ClientMessage::Error { ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") }
request_id,
message: format!("Screenshot failed: {e}"),
}
} }
} }
} }
ServerMessage::ExecRequest { request_id, command } => { ServerMessage::InformRequest { request_id, message, title } => {
info!("Exec: {command}"); 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; let mut sh = shell.lock().await;
match sh.run(&command).await { match sh.run(&command, timeout_ms).await {
Ok((stdout, stderr, exit_code)) => ClientMessage::ExecResponse { Ok((stdout, stderr, exit_code)) => {
request_id, let result = if exit_code != 0 {
stdout, let err_line = stderr.lines()
stderr, .map(|l| l.trim())
exit_code, .find(|l| !l.is_empty()
}, && !l.starts_with("In Zeile:")
Err(e) => { && !l.starts_with("+ ")
error!("Exec failed for command {:?}: {e}", command); && !l.starts_with("CategoryInfo")
ClientMessage::Error { && !l.starts_with("FullyQualifiedErrorId"))
request_id, .unwrap_or("error")
message: format!( .to_string();
"Exec failed for command {:?}.\nError: {e}\nContext: persistent shell may have died.", err_line
command } 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) => { Err(e) => {
error!("Click failed at ({x},{y}): {e}"); display::cmd_done("", "execute", &payload, false, &format!("exec failed: {e}"));
ClientMessage::Error { ClientMessage::Error { request_id, message: format!("Exec failed for command {:?}.\nError: {e}", command) }
request_id,
message: format!("Click at ({x},{y}) failed: {e}"),
}
}
}
}
ServerMessage::TypeRequest { request_id, text } => {
info!("Type: {} chars", text.len());
match input::type_text(&text) {
Ok(()) => ClientMessage::Ack { request_id },
Err(e) => {
error!("Type failed: {e}");
ClientMessage::Error {
request_id,
message: format!("Type failed: {e}"),
}
} }
} }
} }
ServerMessage::ListWindowsRequest { request_id } => { ServerMessage::ListWindowsRequest { request_id } => {
info!("ListWindows"); display::cmd_start("🪟", "list windows", "");
match windows_mgmt::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) => { Err(e) => {
error!("ListWindows failed: {e}"); display::cmd_done("🪟", "list windows", "", false, &e);
ClientMessage::Error { request_id, message: e } ClientMessage::Error { request_id, message: e }
} }
} }
} }
ServerMessage::MinimizeAllRequest { request_id } => { ServerMessage::MinimizeAllRequest { request_id } => {
info!("MinimizeAll"); display::cmd_start("🪟", "minimize all", "");
match windows_mgmt::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) => { Err(e) => {
error!("MinimizeAll failed: {e}"); display::cmd_done("🪟", "minimize all", "", false, &e);
ClientMessage::Error { request_id, message: e } ClientMessage::Error { request_id, message: e }
} }
} }
} }
ServerMessage::FocusWindowRequest { request_id, window_id } => { 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) { 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) => { Err(e) => {
error!("FocusWindow failed: {e}"); display::cmd_done("🪟", "focus window", &payload, false, &e);
ClientMessage::Error { request_id, message: e } ClientMessage::Error { request_id, message: e }
} }
} }
} }
ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => { 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) { 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) => { Err(e) => {
error!("MaximizeAndFocus failed: {e}"); display::cmd_done("🪟", "maximize", &payload, false, &e);
ClientMessage::Error { request_id, message: 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 } => { ServerMessage::Ack { request_id } => {
info!("Server ack for {request_id}");
// Nothing to do - server acked something we sent
ClientMessage::Ack { request_id } ClientMessage::Ack { request_id }
} }
ServerMessage::Error { request_id, message } => { ServerMessage::Error { request_id, message } => {
error!("Server error (req={request_id:?}): {message}"); display::err("", &format!("server error: {message}"));
// No meaningful response needed but we need to return something
// Use a dummy ack if we have a request_id
if let Some(rid) = request_id { if let Some(rid) = request_id {
ClientMessage::Ack { request_id: rid } ClientMessage::Ack { request_id: rid }
} else { } else {
ClientMessage::Hello { label: None } ClientMessage::Hello { label: String::new() }
} }
} }
} }

View file

@ -3,10 +3,10 @@ use base64::Engine;
#[cfg(windows)] #[cfg(windows)]
pub fn take_screenshot() -> Result<(String, u32, u32), String> { pub fn take_screenshot() -> Result<(String, u32, u32), String> {
use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Gdi::{ use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
GetDIBits, GetObjectW, SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER, GetDIBits, SelectObject, BITMAPINFO, BITMAPINFOHEADER,
DIB_RGB_COLORS, SRCCOPY, DIB_RGB_COLORS, SRCCOPY,
}; };
use windows::Win32::UI::WindowsAndMessaging::GetDesktopWindow; 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))] #[cfg(not(windows))]
pub fn take_screenshot() -> Result<(String, u32, u32), String> { pub fn take_screenshot() -> Result<(String, u32, u32), String> {
// Stub for non-Windows builds // Stub for non-Windows builds

View file

@ -1,161 +1,51 @@
/// Persistent shell session that keeps a cmd.exe (Windows) or sh (Unix) alive /// Shell execution — each command runs in its own fresh process.
/// between commands, so state like `cd` is preserved. /// On Windows we use powershell.exe -NoProfile so the user's $PROFILE
use std::process::Stdio; /// (which might run `clear`) is never loaded.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use std::time::Duration;
use tokio::process::{Child, ChildStdin, ChildStdout, ChildStderr}; use tokio::process::Command;
use tracing::{debug, warn};
const OUTPUT_TIMEOUT_MS: u64 = 10_000; const DEFAULT_TIMEOUT_MS: u64 = 30_000;
/// Unique sentinel appended after every command to know when output is done.
const SENTINEL: &str = "__HELIOS_DONE__";
pub struct PersistentShell { pub struct PersistentShell;
child: Option<ShellProcess>,
}
struct ShellProcess {
_child: Child,
stdin: ChildStdin,
stdout_lines: tokio::sync::Mutex<BufReader<ChildStdout>>,
stderr_lines: tokio::sync::Mutex<BufReader<ChildStderr>>,
}
impl PersistentShell { impl PersistentShell {
pub fn new() -> Self { pub fn new() -> Self { Self }
Self { child: None }
}
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)] #[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; let mut cmd = Command::new("powershell.exe");
loop { cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]);
let mut line = String::new(); run_captured(cmd, timeout).await
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
));
}
}
}
} }
#[cfg(not(windows))]
// Drain available stderr (non-blocking)
let mut stderr_buf = String::new();
{ {
let mut reader = shell.stderr_lines.lock().await; let mut cmd = Command::new("sh");
let drain_timeout = tokio::time::Duration::from_millis(100); cmd.args(["-c", command]);
loop { run_captured(cmd, timeout).await
let mut line = String::new();
match tokio::time::timeout(drain_timeout, reader.read_line(&mut line)).await {
Ok(Ok(0)) | Err(_) => break,
Ok(Ok(_)) => stderr_buf.push_str(&line),
Ok(Err(_)) => break,
}
}
} }
}
Ok((stdout_buf, stderr_buf, exit_code)) }
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 ────────────────────────────────────────────────── // ── Windows implementation ──────────────────────────────────────────────────
#[cfg(windows)] #[cfg(windows)]
mod win_impl { mod win_impl {
use super::*; use super::*;
use std::sync::Mutex;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_CLOAKED};
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
BringWindowToTop, EnumWindows, GetWindowTextW, IsWindowVisible, SetForegroundWindow, BringWindowToTop, EnumWindows, GetWindowTextW,
ShowWindow, SW_MAXIMIZE, SW_MINIMIZE, SHOW_WINDOW_CMD, 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 { unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let list = &mut *(lparam.0 as *mut Vec<HWND>); let list = &mut *(lparam.0 as *mut Vec<HWND>);
list.push(hwnd); list.push(hwnd);
@ -30,25 +39,123 @@ mod win_impl {
list 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 { fn hwnd_title(hwnd: HWND) -> String {
let mut buf = [0u16; 512]; let mut buf = [0u16; 512];
let len = unsafe { GetWindowTextW(hwnd, &mut buf) }; let len = unsafe { GetWindowTextW(hwnd, &mut buf) };
String::from_utf16_lossy(&buf[..len as usize]) 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> { pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
let hwnds = get_all_hwnds(); let hwnds = get_all_hwnds();
let mut windows = Vec::new();
for hwnd in hwnds { // Collect visible windows with non-empty titles
let visible = unsafe { IsWindowVisible(hwnd).as_bool() }; let mut raw_windows: Vec<(HWND, String, String)> = Vec::new();
let title = hwnd_title(hwnd); 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() { if title.is_empty() {
continue; 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 { windows.push(WindowInfo {
id: hwnd.0 as u64, id: hwnd.0 as u64,
title, title,
visible, label,
visible: true,
}); });
} }
Ok(windows) Ok(windows)
@ -68,12 +175,17 @@ mod win_impl {
Ok(()) 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> { pub fn focus_window(window_id: u64) -> Result<(), String> {
let hwnd = HWND(window_id as isize); let hwnd = HWND(window_id as isize);
unsafe { unsafe { force_foreground(hwnd); }
BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?;
SetForegroundWindow(hwnd);
}
Ok(()) Ok(())
} }
@ -81,8 +193,7 @@ mod win_impl {
let hwnd = HWND(window_id as isize); let hwnd = HWND(window_id as isize);
unsafe { unsafe {
ShowWindow(hwnd, SW_MAXIMIZE); ShowWindow(hwnd, SW_MAXIMIZE);
BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?; force_foreground(hwnd);
SetForegroundWindow(hwnd);
} }
Ok(()) Ok(())
} }

View file

@ -1,36 +1,74 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo { pub struct WindowInfo {
pub id: u64, pub id: u64,
pub title: String, pub title: String,
pub label: String,
pub visible: bool, 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 /// Messages sent from the relay server to a connected client
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage { pub enum ServerMessage {
/// Request a screenshot from the client /// Request a full-screen screenshot
ScreenshotRequest { request_id: Uuid }, 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 /// Execute a shell command on the client
ExecRequest { ExecRequest {
request_id: Uuid, request_id: Uuid,
command: String, command: String,
}, timeout_ms: Option<u64>,
/// Simulate a mouse click
ClickRequest {
request_id: Uuid,
x: i32,
y: i32,
button: MouseButton,
},
/// Type text on the client
TypeRequest {
request_id: Uuid,
text: String,
}, },
/// Acknowledge a client message /// Acknowledge a client message
Ack { request_id: Uuid }, Ack { request_id: Uuid },
@ -47,14 +85,39 @@ pub enum ServerMessage {
FocusWindowRequest { request_id: Uuid, window_id: u64 }, FocusWindowRequest { request_id: Uuid, window_id: u64 },
/// Maximize a window and bring it to the foreground /// Maximize a window and bring it to the foreground
MaximizeAndFocusRequest { request_id: Uuid, window_id: u64 }, 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 /// Messages sent from the client to the relay server
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage { pub enum ClientMessage {
/// Client registers itself with optional display name /// Client registers itself with its device label
Hello { label: Option<String> }, Hello { label: String },
/// Response to a screenshot request — base64-encoded PNG /// Response to a screenshot request — base64-encoded PNG
ScreenshotResponse { ScreenshotResponse {
request_id: Uuid, request_id: Uuid,
@ -69,7 +132,7 @@ pub enum ClientMessage {
stderr: String, stderr: String,
exit_code: i32, exit_code: i32,
}, },
/// Generic acknowledgement for click/type/minimize-all/focus/maximize /// Generic acknowledgement
Ack { request_id: Uuid }, Ack { request_id: Uuid },
/// Client error response /// Client error response
Error { Error {
@ -81,32 +144,64 @@ pub enum ClientMessage {
request_id: Uuid, request_id: Uuid,
windows: Vec<WindowInfo>, windows: Vec<WindowInfo>,
}, },
} /// Response to a version request
VersionResponse {
/// Mouse button variants request_id: Uuid,
#[derive(Debug, Clone, Serialize, Deserialize)] version: String,
#[serde(rename_all = "lowercase")] commit: String,
pub enum MouseButton { },
Left, LogsResponse {
Right, request_id: Uuid,
Middle, content: String,
} log_path: String,
},
impl Default for MouseButton { /// Response to a download request
fn default() -> Self { DownloadResponse {
MouseButton::Left 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_server_message_serialization() { fn test_server_message_serialization() {
let msg = ServerMessage::ExecRequest { let msg = ServerMessage::ExecRequest {
request_id: Uuid::nil(), request_id: Uuid::nil(),
command: "echo hello".into(), command: "echo hello".into(),
timeout_ms: None,
}; };
let json = serde_json::to_string(&msg).unwrap(); let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("exec_request")); assert!(json.contains("exec_request"));
@ -115,25 +210,9 @@ mod tests {
#[test] #[test]
fn test_client_message_serialization() { 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(); let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("hello")); assert!(json.contains("hello"));
assert!(json.contains("test-pc")); 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] [package]
name = "helios-server" name = "helios-remote-relay"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
name = "helios-server" name = "helios-remote-relay"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
@ -22,3 +22,4 @@ tokio-tungstenite = "0.21"
futures-util = "0.3" futures-util = "0.3"
dashmap = "5" dashmap = "5"
anyhow = "1" 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 std::time::Duration;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
Json, Json,
@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use tracing::error; use tracing::error;
use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage}; use helios_common::protocol::{ClientMessage, ServerMessage};
use crate::AppState; use crate::AppState;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@ -21,33 +21,29 @@ pub struct ErrorBody {
pub error: String, pub error: String,
} }
fn not_found(session_id: &str) -> (StatusCode, Json<ErrorBody>) { fn not_found(label: &str) -> (StatusCode, Json<ErrorBody>) {
( (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(ErrorBody { 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, StatusCode::GATEWAY_TIMEOUT,
Json(ErrorBody { Json(ErrorBody {
error: format!( error: format!("Timed out waiting for client response (device='{label}', op='{op}')"),
"Timed out waiting for client response (session='{session_id}', 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, StatusCode::BAD_GATEWAY,
Json(ErrorBody { Json(ErrorBody {
error: format!( error: format!("Failed to send command to client — may have disconnected (device='{label}', op='{op}')"),
"Failed to send command to client — client may have disconnected (session='{session_id}', op='{op}')"
),
}), }),
) )
} }
@ -56,282 +52,477 @@ fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
async fn dispatch<F>( async fn dispatch<F>(
state: &AppState, state: &AppState,
session_id: &str, label: &str,
op: &str, op: &str,
make_msg: F, make_msg: F,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)> ) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where where
F: FnOnce(Uuid) -> ServerMessage, F: FnOnce(Uuid) -> ServerMessage,
{ {
let id = session_id.parse::<Uuid>().map_err(|_| { dispatch_with_timeout(state, label, op, make_msg, REQUEST_TIMEOUT).await
( }
StatusCode::BAD_REQUEST,
Json(ErrorBody {
error: format!("Invalid session id: '{session_id}'"),
}),
)
})?;
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 let tx = state
.sessions .sessions
.get_cmd_tx(&id) .get_cmd_tx(label)
.ok_or_else(|| not_found(session_id))?; .ok_or_else(|| not_found(label))?;
let request_id = Uuid::new_v4(); let request_id = Uuid::new_v4();
let rx = state.sessions.register_pending(request_id); let rx = state.sessions.register_pending(request_id);
let msg = make_msg(request_id); let msg = make_msg(request_id);
tx.send(msg).await.map_err(|e| { tx.send(msg).await.map_err(|e| {
error!("Channel send failed for session={session_id}, op={op}: {e}"); error!("Channel send failed for device={label}, op={op}: {e}");
send_error(session_id, op) 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(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(send_error(session_id, op)), Ok(Err(_)) => Err(send_error(label, op)),
Err(_) => Err(timeout_error(session_id, op)), Err(_) => Err(timeout_error(label, op)),
} }
} }
// ── Handlers ───────────────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────────
/// GET /sessions — list all connected clients /// GET /devices — list all connected clients
pub async fn list_sessions(State(state): State<AppState>) -> Json<serde_json::Value> { pub async fn list_devices(State(state): State<AppState>) -> Json<serde_json::Value> {
let sessions = state.sessions.list(); let devices = state.sessions.list();
Json(serde_json::json!({ "sessions": sessions })) Json(serde_json::json!({ "devices": devices }))
} }
/// POST /sessions/:id/screenshot /// POST /devices/:label/screenshot — full screen screenshot
pub async fn request_screenshot( pub async fn request_screenshot(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "screenshot", |rid| { match dispatch(&state, &label, "screenshot", |rid| {
ServerMessage::ScreenshotRequest { request_id: rid } ServerMessage::ScreenshotRequest { request_id: rid }
}) }).await {
.await Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => (
{
Ok(ClientMessage::ScreenshotResponse {
image_base64,
width,
height,
..
}) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ Json(serde_json::json!({ "image_base64": image_base64, "width": width, "height": height })),
"image_base64": image_base64, ).into_response(),
"width": width,
"height": height,
})),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.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)] #[derive(Deserialize)]
pub struct ExecBody { pub struct ExecBody {
pub command: String, pub command: String,
pub timeout_ms: Option<u64>,
} }
pub async fn request_exec( pub async fn request_exec(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<ExecBody>, Json(body): Json<ExecBody>,
) -> impl IntoResponse { ) -> 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, request_id: rid,
command: body.command.clone(), command: body.command.clone(),
}) timeout_ms: body.timeout_ms,
.await }, server_timeout).await {
{ Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => (
Ok(ClientMessage::ExecResponse {
stdout,
stderr,
exit_code,
..
}) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ Json(serde_json::json!({ "stdout": stdout, "stderr": stderr, "exit_code": exit_code })),
"stdout": stdout, ).into_response(),
"stderr": stderr,
"exit_code": exit_code,
})),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/click /// GET /devices/:label/windows
#[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
pub async fn list_windows( pub async fn list_windows(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "list_windows", |rid| { match dispatch(&state, &label, "list_windows", |rid| {
ServerMessage::ListWindowsRequest { request_id: rid } ServerMessage::ListWindowsRequest { request_id: rid }
}) }).await {
.await
{
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => ( Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "windows": windows })), Json(serde_json::json!({ "windows": windows })),
) ).into_response(),
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/windows/minimize-all /// POST /devices/:label/windows/minimize-all
pub async fn minimize_all( pub async fn minimize_all(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "minimize_all", |rid| { match dispatch(&state, &label, "minimize_all", |rid| {
ServerMessage::MinimizeAllRequest { request_id: rid } ServerMessage::MinimizeAllRequest { request_id: rid }
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.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( pub async fn focus_window(
Path((session_id, window_id)): Path<(String, u64)>, Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "focus_window", |rid| { match dispatch(&state, &label, "focus_window", |rid| {
ServerMessage::FocusWindowRequest { request_id: rid, window_id } ServerMessage::FocusWindowRequest { request_id: rid, window_id }
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.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( 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>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> 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 } ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/label /// GET /version — server version (public, no auth)
#[derive(Deserialize)] pub async fn server_version() -> impl IntoResponse {
pub struct LabelBody { Json(serde_json::json!({
pub label: String, "commit": env!("GIT_COMMIT"),
}))
} }
pub async fn set_label( /// GET /devices/:label/version — client version
Path(session_id): Path<String>, pub async fn client_version(
Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<LabelBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let id = match session_id.parse::<Uuid>() { match dispatch(&state, &label, "version", |rid| {
Ok(id) => id, ServerMessage::VersionRequest { request_id: rid }
Err(_) => { }).await {
return ( Ok(ClientMessage::VersionResponse { version, commit, .. }) => (
StatusCode::BAD_REQUEST, StatusCode::OK,
Json(serde_json::json!({ "error": format!("Invalid session id: '{session_id}'") })), Json(serde_json::json!({ "version": version, "commit": commit })),
) ).into_response(),
.into_response() Ok(ClientMessage::Error { message, .. }) => (
} StatusCode::INTERNAL_SERVER_ERROR,
}; Json(serde_json::json!({ "error": message })),
).into_response(),
if state.sessions.set_label(&id, body.label.clone()) { Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response() Err(e) => e.into_response(),
} else { }
not_found(&session_id).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()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
const GIT_COMMIT: &str = env!("GIT_COMMIT");
info!("helios-server ({GIT_COMMIT})");
let api_key = std::env::var("HELIOS_API_KEY") let api_key = std::env::var("HELIOS_API_KEY")
.unwrap_or_else(|_| "dev-secret".to_string()); .unwrap_or_else(|_| "dev-secret".to_string());
@ -42,20 +45,30 @@ async fn main() -> anyhow::Result<()> {
}; };
let protected = Router::new() let protected = Router::new()
.route("/sessions", get(api::list_sessions)) .route("/devices", get(api::list_devices))
.route("/sessions/:id/screenshot", post(api::request_screenshot)) .route("/devices/:label/screenshot", post(api::request_screenshot))
.route("/sessions/:id/exec", post(api::request_exec)) .route("/devices/:label/exec", post(api::request_exec))
.route("/sessions/:id/click", post(api::request_click)) .route("/devices/:label/prompt", post(api::prompt_user))
.route("/sessions/:id/type", post(api::request_type)) .route("/devices/:label/inform", post(api::inform_user))
.route("/sessions/:id/label", post(api::set_label)) .route("/devices/:label/windows", get(api::list_windows))
.route("/sessions/:id/windows", get(api::list_windows)) .route("/devices/:label/windows/minimize-all", post(api::minimize_all))
.route("/sessions/:id/windows/minimize-all", post(api::minimize_all)) .route("/devices/:label/logs", get(api::logs))
.route("/sessions/:id/windows/:window_id/focus", post(api::focus_window)) .route("/devices/:label/windows/:window_id/screenshot", post(api::window_screenshot))
.route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus)) .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)); .layer(middleware::from_fn_with_state(state.clone(), require_api_key));
let app = Router::new() let app = Router::new()
.route("/ws", get(ws_handler::ws_upgrade)) .route("/ws", get(ws_handler::ws_upgrade))
.route("/version", get(api::server_version))
.merge(protected) .merge(protected)
.with_state(state); .with_state(state);

View file

@ -4,11 +4,11 @@ use uuid::Uuid;
use serde::Serialize; use serde::Serialize;
use helios_common::protocol::{ClientMessage, ServerMessage}; 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)] #[derive(Debug, Clone)]
pub struct Session { pub struct Session {
pub id: Uuid, pub label: String,
pub label: Option<String>,
/// Channel to send commands to the WS handler for this session /// Channel to send commands to the WS handler for this session
pub cmd_tx: mpsc::Sender<ServerMessage>, pub cmd_tx: mpsc::Sender<ServerMessage>,
} }
@ -16,22 +16,20 @@ pub struct Session {
/// Serializable view of a session for the REST API /// Serializable view of a session for the REST API
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SessionInfo { pub struct SessionInfo {
pub id: Uuid, pub label: String,
pub label: Option<String>,
} }
impl From<&Session> for SessionInfo { impl From<&Session> for SessionInfo {
fn from(s: &Session) -> Self { fn from(s: &Session) -> Self {
SessionInfo { SessionInfo {
id: s.id,
label: s.label.clone(), label: s.label.clone(),
} }
} }
} }
pub struct SessionStore { pub struct SessionStore {
/// Active sessions by ID /// Active sessions keyed by device label
sessions: DashMap<Uuid, Session>, sessions: DashMap<String, Session>,
/// Pending request callbacks by request_id /// Pending request callbacks by request_id
pending: DashMap<Uuid, oneshot::Sender<ClientMessage>>, pending: DashMap<Uuid, oneshot::Sender<ClientMessage>>,
} }
@ -45,24 +43,15 @@ impl SessionStore {
} }
pub fn insert(&self, session: Session) { 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) { pub fn remove(&self, label: &str) {
self.sessions.remove(id); self.sessions.remove(label);
} }
pub fn get_cmd_tx(&self, id: &Uuid) -> Option<mpsc::Sender<ServerMessage>> { pub fn get_cmd_tx(&self, label: &str) -> Option<mpsc::Sender<ServerMessage>> {
self.sessions.get(id).map(|s| s.cmd_tx.clone()) self.sessions.get(label).map(|s| s.cmd_tx.clone())
}
pub fn set_label(&self, id: &Uuid, label: String) -> bool {
if let Some(mut s) = self.sessions.get_mut(id) {
s.label = Some(label);
true
} else {
false
}
} }
pub fn list(&self) -> Vec<SessionInfo> { pub fn list(&self) -> Vec<SessionInfo> {
@ -77,7 +66,6 @@ impl SessionStore {
} }
/// Deliver a client response to the waiting request handler. /// 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 { pub fn resolve_pending(&self, request_id: Uuid, msg: ClientMessage) -> bool {
if let Some((_, tx)) = self.pending.remove(&request_id) { if let Some((_, tx)) = self.pending.remove(&request_id) {
let _ = tx.send(msg); let _ = tx.send(msg);

View file

@ -5,7 +5,6 @@ use axum::{
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use uuid::Uuid;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use helios_common::protocol::{ClientMessage, ServerMessage}; use helios_common::protocol::{ClientMessage, ServerMessage};
@ -19,32 +18,57 @@ pub async fn ws_upgrade(
} }
async fn handle_socket(socket: WebSocket, state: AppState) { 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 (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 { let session = Session {
id: session_id, label: label.clone(),
label: None,
cmd_tx, cmd_tx,
}; };
state.sessions.insert(session); state.sessions.insert(session);
info!("Client connected: session={session_id}"); info!("Client connected: device={label}");
let (mut ws_tx, mut ws_rx) = socket.split();
// Spawn task: forward server commands → WS // Spawn task: forward server commands → WS
let label_clone = label.clone();
let send_task = tokio::spawn(async move { let send_task = tokio::spawn(async move {
while let Some(msg) = cmd_rx.recv().await { while let Some(msg) = cmd_rx.recv().await {
match serde_json::to_string(&msg) { match serde_json::to_string(&msg) {
Ok(json) => { Ok(json) => {
if let Err(e) = ws_tx.send(Message::Text(json.into())).await { 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; break;
} }
} }
Err(e) => { 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 { match result {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&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) => { Err(e) => {
warn!("Invalid JSON from session={session_id}: {e} | raw={text}"); warn!("Invalid JSON from device={label}: {e}");
} }
} }
} }
Ok(Message::Close(_)) => { Ok(Message::Close(_)) => {
info!("Client disconnected gracefully: session={session_id}"); info!("Client disconnected gracefully: device={label}");
break; break;
} }
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {} Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {}
Err(e) => { Err(e) => {
error!("WS receive error for session={session_id}: {e}"); error!("WS receive error for device={label}: {e}");
break; break;
} }
} }
} }
send_task.abort(); send_task.abort();
state.sessions.remove(&session_id); state.sessions.remove(&label);
info!("Session cleaned up: session={session_id}"); 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 { match &msg {
ClientMessage::Hello { label } => { ClientMessage::Hello { .. } => {
if let Some(lbl) = label { debug!("Duplicate Hello from device={label}, ignoring");
state.sessions.set_label(&session_id, lbl.clone());
}
debug!("Hello from session={session_id}, label={label:?}");
} }
ClientMessage::ScreenshotResponse { request_id, .. } ClientMessage::ScreenshotResponse { request_id, .. }
| ClientMessage::ExecResponse { request_id, .. } | ClientMessage::ExecResponse { request_id, .. }
| ClientMessage::ListWindowsResponse { 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::Ack { request_id }
| ClientMessage::Error { request_id, .. } => { | ClientMessage::Error { request_id, .. } => {
let rid = *request_id; let rid = *request_id;
if !state.sessions.resolve_pending(rid, msg) { 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 # 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. # IMPORTANT: Windows Defender may block the executable.
# If the download or launch fails, disable real-time protection first (run as Admin): # If the download or launch fails, disable real-time protection first (run as Admin):
@ -9,11 +9,17 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$url = "https://github.com/agent-helios/helios-remote/releases/latest/download/helios-remote-client-windows.exe" $url = "https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe"
$dest = "$env:TEMP\helios-remote.exe" $dest = "$env:USERPROFILE\Desktop\Helios Remote.exe"
Write-Host "Downloading helios-remote client..." 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..." Write-Host "Starting..."
Start-Process -FilePath $dest -NoNewWindow Start-Process -FilePath $dest