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
This commit is contained in:
parent
5fd01a423d
commit
0b4a6de8ae
14 changed files with 736 additions and 1180 deletions
160
README.md
160
README.md
|
|
@ -33,7 +33,8 @@ helios-remote/
|
|||
├── crates/
|
||||
│ ├── common/ # Shared protocol types, WebSocket message definitions
|
||||
│ ├── server/ # Relay server (REST API + WebSocket hub)
|
||||
│ └── client/ # Windows client — Phase 2 (stub only)
|
||||
│ └── client/ # Windows client
|
||||
├── remote.py # CLI wrapper for the REST API
|
||||
├── Cargo.toml # Workspace root
|
||||
└── README.md
|
||||
```
|
||||
|
|
@ -46,44 +47,56 @@ AI Agent
|
|||
▼
|
||||
helios-server ──WebSocket── helios-client (Windows)
|
||||
│ │
|
||||
POST /sessions/:id/screenshot │ Captures screen → base64 PNG
|
||||
POST /sessions/:id/exec │ Runs command in persistent shell
|
||||
POST /sessions/:id/click │ Simulates mouse click
|
||||
POST /sessions/:id/type │ Types text
|
||||
POST /devices/:label/screenshot │ Captures screen → base64 PNG
|
||||
POST /devices/:label/exec │ Runs command in persistent shell
|
||||
```
|
||||
|
||||
1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` message.
|
||||
2. The **AI agent** calls the REST API to issue commands.
|
||||
3. The relay server forwards commands to the correct client session and streams back responses.
|
||||
1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` with its device label.
|
||||
2. The **AI agent** calls the REST API using the device label to issue commands.
|
||||
3. The relay server forwards commands to the correct client and streams back responses.
|
||||
|
||||
### Device Labels
|
||||
|
||||
Device labels are the **sole identifier** for connected clients. Labels must be:
|
||||
- **Lowercase** only
|
||||
- **No whitespace**
|
||||
- Only `a-z`, `0-9`, `-`, `_` as characters
|
||||
|
||||
Labels are set during first-time client setup. Examples: `moritz_pc`, `work-desktop`, `gaming-rig`
|
||||
|
||||
### Single Instance
|
||||
|
||||
Only one helios-remote client can run per device. The client uses a PID-based lock file to enforce this.
|
||||
|
||||
## Server
|
||||
|
||||
### REST API
|
||||
|
||||
All endpoints require the `X-Api-Key` header.
|
||||
All endpoints (except `/version` and `/ws`) require the `X-Api-Key` header.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/sessions` | List all connected clients |
|
||||
| `POST` | `/sessions/:id/screenshot` | Request a screenshot (returns base64 PNG) |
|
||||
| `POST` | `/sessions/:id/exec` | Execute a shell command |
|
||||
| `POST` | `/sessions/:id/click` | Simulate a mouse click |
|
||||
| `POST` | `/sessions/:id/type` | Type text |
|
||||
| `POST` | `/sessions/:id/label` | Rename a session |
|
||||
| `GET` | `/sessions/:id/windows` | List all windows |
|
||||
| `POST` | `/sessions/:id/windows/minimize-all` | Minimize all windows |
|
||||
| `POST` | `/sessions/:id/windows/:window_id/focus` | Focus a window |
|
||||
| `POST` | `/sessions/:id/windows/:window_id/maximize` | Maximize and focus a window |
|
||||
| `POST` | `/sessions/:id/run` | Launch a program (fire-and-forget) |
|
||||
| `GET` | `/sessions/:id/clipboard` | Get clipboard contents |
|
||||
| `POST` | `/sessions/:id/clipboard` | Set clipboard contents |
|
||||
| `GET` | `/sessions/:id/version` | Get client version |
|
||||
| `POST` | `/sessions/:id/upload` | Upload a file to the client |
|
||||
| `GET` | `/sessions/:id/download?path=...` | Download a file from the client |
|
||||
| `GET` | `/devices` | List all connected devices |
|
||||
| `POST` | `/devices/:label/screenshot` | Full screen screenshot (base64 PNG) |
|
||||
| `POST` | `/devices/:label/exec` | Execute a shell command |
|
||||
| `GET` | `/devices/:label/windows` | List visible windows (with labels) |
|
||||
| `POST` | `/devices/:label/windows/minimize-all` | Minimize all windows |
|
||||
| `POST` | `/devices/:label/windows/:window_id/screenshot` | Screenshot a specific window |
|
||||
| `POST` | `/devices/:label/windows/:window_id/focus` | Focus a window |
|
||||
| `POST` | `/devices/:label/windows/:window_id/maximize` | Maximize and focus a window |
|
||||
| `POST` | `/devices/:label/prompt` | Show a MessageBox (blocks until OK) |
|
||||
| `POST` | `/devices/:label/run` | Launch a program (fire-and-forget) |
|
||||
| `GET` | `/devices/:label/clipboard` | Get clipboard contents |
|
||||
| `POST` | `/devices/:label/clipboard` | Set clipboard contents |
|
||||
| `GET` | `/devices/:label/version` | Get client version/commit |
|
||||
| `POST` | `/devices/:label/upload` | Upload a file to the client |
|
||||
| `GET` | `/devices/:label/download?path=...` | Download a file from the client |
|
||||
| `GET` | `/devices/:label/logs` | Fetch client log tail |
|
||||
| `GET` | `/version` | Server version/commit (no auth) |
|
||||
|
||||
### WebSocket
|
||||
|
||||
Clients connect to `ws://host:3000/ws`. No auth required at the transport layer — the server trusts all WS connections as client agents.
|
||||
Clients connect to `ws://host:3000/ws`. The first message must be a `Hello` with the device label.
|
||||
|
||||
### Running the Server
|
||||
|
||||
|
|
@ -91,8 +104,6 @@ Clients connect to `ws://host:3000/ws`. No auth required at the transport layer
|
|||
HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `HELIOS_API_KEY` | `dev-secret` | API key for REST endpoints |
|
||||
|
|
@ -102,74 +113,67 @@ Environment variables:
|
|||
### Example API Usage
|
||||
|
||||
```bash
|
||||
# List sessions
|
||||
curl -H "X-Api-Key: your-secret-key" http://localhost:3000/sessions
|
||||
# List devices
|
||||
curl -H "X-Api-Key: your-secret-key" http://localhost:3000/devices
|
||||
|
||||
# Take a screenshot
|
||||
# Take a full-screen screenshot
|
||||
curl -s -X POST -H "X-Api-Key: your-secret-key" \
|
||||
http://localhost:3000/sessions/<session-id>/screenshot
|
||||
http://localhost:3000/devices/moritz_pc/screenshot
|
||||
|
||||
# Run a command
|
||||
curl -s -X POST -H "X-Api-Key: your-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"command": "whoami"}' \
|
||||
http://localhost:3000/sessions/<session-id>/exec
|
||||
|
||||
# Click at coordinates
|
||||
curl -s -X POST -H "X-Api-Key: your-secret-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"x": 100, "y": 200, "button": "left"}' \
|
||||
http://localhost:3000/sessions/<session-id>/click
|
||||
http://localhost:3000/devices/moritz_pc/exec
|
||||
```
|
||||
|
||||
## remote.py CLI
|
||||
|
||||
The `skills/helios-remote/remote.py` script provides a simple CLI wrapper around the REST API.
|
||||
|
||||
### Label Routing
|
||||
|
||||
All commands accept either a UUID or a label name as `session_id`. If the value is not a UUID, the script resolves it by looking up the label across all connected sessions:
|
||||
|
||||
```bash
|
||||
python remote.py screenshot "Moritz PC" # resolves label → UUID automatically
|
||||
python remote.py exec "Moritz PC" whoami
|
||||
```
|
||||
The `remote.py` script provides a CLI wrapper around the REST API.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
python remote.py sessions # list sessions
|
||||
python remote.py screenshot <session> # capture screenshot → /tmp/helios-remote-screenshot.png
|
||||
python remote.py exec <session> <command...> # run shell command (PowerShell, no wrapper needed)
|
||||
python remote.py exec <session> --timeout 600 <command...> # with custom timeout (seconds, default: 30)
|
||||
python remote.py prompt <session> "Please click Save, then OK" # ask user to do something manually
|
||||
python remote.py click <session> <x> <y> # mouse click
|
||||
python remote.py type <session> <text> # keyboard input
|
||||
python remote.py windows <session> # list windows
|
||||
python remote.py find-window <session> <title> # filter windows by title substring
|
||||
python remote.py minimize-all <session> # minimize all windows
|
||||
python remote.py focus <session> <window_id> # focus window
|
||||
python remote.py maximize <session> <window_id> # maximize and focus window
|
||||
python remote.py run <session> <program> [args...] # launch program (fire-and-forget)
|
||||
python remote.py clipboard-get <session> # get clipboard text
|
||||
python remote.py clipboard-set <session> <text> # set clipboard text
|
||||
python remote.py upload <session> <local> <remote> # upload file
|
||||
python remote.py download <session> <remote> <local> # download file
|
||||
python remote.py screenshot-window <session> <window_id_or_title> # screenshot a specific window
|
||||
python remote.py screenshot-window <session> <title> --output /tmp/out.png # custom output path
|
||||
python remote.py wait-for-window <session> <title> # poll until window appears
|
||||
python remote.py wait-for-window <session> <title> --timeout 60 # custom timeout (default: 30s)
|
||||
python remote.py label <session> <new_name> # assign a human-readable name to session
|
||||
python remote.py status <session> # compare relay / remote.py / client commit
|
||||
python remote.py logs <session> # fetch last 100 lines of client log
|
||||
python remote.py logs <session> --lines 200 # custom line count
|
||||
python remote.py version <session> # client version
|
||||
python remote.py server-version # server version (no auth required)
|
||||
python remote.py devices # list connected devices
|
||||
python remote.py screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
|
||||
python remote.py screenshot <device> google_chrome # screenshot a specific window by label
|
||||
python remote.py exec <device> <command...> # run shell command (PowerShell)
|
||||
python remote.py exec <device> --timeout 600 <command...> # with custom timeout (seconds)
|
||||
python remote.py windows <device> # list visible windows (with labels)
|
||||
python remote.py focus <device> <window_label> # focus a window
|
||||
python remote.py maximize <device> <window_label> # maximize and focus a window
|
||||
python remote.py minimize-all <device> # minimize all windows
|
||||
python remote.py prompt <device> "Please click Save" # ask user to do something manually
|
||||
python remote.py prompt <device> "message" --title "Title" # with custom dialog title
|
||||
python remote.py run <device> <program> [args...] # launch program (fire-and-forget)
|
||||
python remote.py clipboard-get <device> # get clipboard text
|
||||
python remote.py clipboard-set <device> <text> # set clipboard text
|
||||
python remote.py upload <device> <local> <remote> # upload file
|
||||
python remote.py download <device> <remote> <local> # download file
|
||||
python remote.py version <device> # compare relay/remote.py/client commits
|
||||
python remote.py logs <device> # fetch last 100 lines of client log
|
||||
python remote.py logs <device> --lines 200 # custom line count
|
||||
```
|
||||
|
||||
## Client (Phase 2)
|
||||
### Window Labels
|
||||
|
||||
See [`crates/client/README.md`](crates/client/README.md) for the planned Windows client implementation.
|
||||
Windows are identified by human-readable labels (same format as device labels: lowercase, no whitespace). Use `windows` to list them:
|
||||
|
||||
```bash
|
||||
$ python remote.py windows moritz_pc
|
||||
Label Title
|
||||
----------------------------------------------------------------------
|
||||
google_chrome Google Chrome
|
||||
discord Discord
|
||||
visual_studio_code Visual Studio Code
|
||||
```
|
||||
|
||||
Then use the label in `screenshot`, `focus`, or `maximize`:
|
||||
|
||||
```bash
|
||||
python remote.py screenshot moritz_pc google_chrome
|
||||
python remote.py focus moritz_pc discord
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
|||
130
SKILL.md
130
SKILL.md
|
|
@ -1,110 +1,92 @@
|
|||
# Skill: helios-remote
|
||||
|
||||
> **Hinweis:** Dieses Repo enthält außer diesem Skill noch Rust-Code (Client, Server) und Assets. Die anderen Dateien und Ordner im Repo sind für den Skill nicht relevant - nicht lesen, nicht anfassen.
|
||||
> **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.
|
||||
|
||||
Steuere PCs die über den Helios Remote Relay-Server verbunden sind.
|
||||
Control PCs connected to the Helios Remote Relay Server.
|
||||
|
||||
## Wann nutzen
|
||||
## When to use
|
||||
|
||||
Wenn Moritz sagt, dass ich etwas auf einem verbundenen PC tun soll:
|
||||
- "Mach auf meinem PC..."
|
||||
- "Schau mal was auf dem Rechner läuft..."
|
||||
- "Nimm einen Screenshot von..."
|
||||
- "Klick auf X, tippe Y..."
|
||||
- Allgemein: Remote-Zugriff auf einen PC der gerade online ist
|
||||
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/remote.py`
|
||||
- **Config:** `skills/helios-remote/config.env` (URL + API-Key, nicht ändern)
|
||||
- **Config:** `skills/helios-remote/config.env` (URL + API key, don't modify)
|
||||
- `SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote`
|
||||
|
||||
## Wichtige Regeln
|
||||
## Important Rules
|
||||
|
||||
- **Vor destruktiven Aktionen** (Wallpaper, Registry, Systemeinstellungen, Dateien löschen) immer erst den aktuellen Zustand lesen und merken!
|
||||
- Wallpaper auslesen: `(Get-ItemProperty 'HKCU:\Control Panel\Desktop').WallPaper`
|
||||
- **Label-Routing:** `session_id` kann UUID oder Label-Name sein → einfach `"Moritz PC"` verwenden
|
||||
- **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`)
|
||||
|
||||
## Befehle
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote
|
||||
|
||||
# Sessions
|
||||
python $SKILL_DIR/remote.py sessions
|
||||
python $SKILL_DIR/remote.py server-version
|
||||
python $SKILL_DIR/remote.py version "Moritz PC"
|
||||
# Devices
|
||||
python $SKILL_DIR/remote.py devices
|
||||
|
||||
# Screenshot → /tmp/helios-remote-screenshot.png
|
||||
# IMMER screenshot-window bevorzugen (spart Bandbreite)!
|
||||
python $SKILL_DIR/remote.py screenshot-window "Moritz PC" "chrome" # aktives Fenster by title
|
||||
python $SKILL_DIR/remote.py screenshot "Moritz PC" # nur wenn kein Fenster bekannt
|
||||
# ALWAYS prefer window screenshots (saves bandwidth)!
|
||||
python $SKILL_DIR/remote.py screenshot moritz_pc google_chrome # window by label
|
||||
python $SKILL_DIR/remote.py screenshot moritz_pc screen # full screen only when no window known
|
||||
|
||||
# Shell-Befehl (PowerShell, kein wrapper nötig)
|
||||
python $SKILL_DIR/remote.py exec "Moritz PC" "Get-Process"
|
||||
python $SKILL_DIR/remote.py exec "Moritz PC" "hostname"
|
||||
# Mit längerer Timeout für Downloads etc. (default: 30s)
|
||||
python $SKILL_DIR/remote.py exec "Moritz PC" --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip"
|
||||
# Shell command (PowerShell, no wrapper needed)
|
||||
python $SKILL_DIR/remote.py exec moritz_pc "Get-Process"
|
||||
python $SKILL_DIR/remote.py exec moritz_pc "hostname"
|
||||
# With longer timeout for downloads etc. (default: 30s)
|
||||
python $SKILL_DIR/remote.py exec moritz_pc --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip"
|
||||
|
||||
# Mausklick / Text tippen
|
||||
python $SKILL_DIR/remote.py click "Moritz PC" 960 540
|
||||
python $SKILL_DIR/remote.py type "Moritz PC" "Hello World"
|
||||
# Windows (visible only, shown with human-readable labels)
|
||||
python $SKILL_DIR/remote.py windows moritz_pc
|
||||
python $SKILL_DIR/remote.py focus moritz_pc discord
|
||||
python $SKILL_DIR/remote.py maximize moritz_pc google_chrome
|
||||
python $SKILL_DIR/remote.py minimize-all moritz_pc
|
||||
|
||||
# Fenster (nur sichtbare werden angezeigt)
|
||||
python $SKILL_DIR/remote.py windows "Moritz PC"
|
||||
python $SKILL_DIR/remote.py find-window "Moritz PC" "chrome"
|
||||
python $SKILL_DIR/remote.py focus "Moritz PC" <window_id>
|
||||
python $SKILL_DIR/remote.py maximize "Moritz PC" <window_id>
|
||||
python $SKILL_DIR/remote.py minimize-all "Moritz PC"
|
||||
# Launch program (fire-and-forget)
|
||||
python $SKILL_DIR/remote.py run moritz_pc notepad.exe
|
||||
|
||||
# Programm starten (fire-and-forget)
|
||||
python $SKILL_DIR/remote.py run "Moritz PC" notepad.exe
|
||||
|
||||
# User um etwas bitten (zeigt MessageBox, blockiert bis OK)
|
||||
python $SKILL_DIR/remote.py prompt "Moritz PC" "Bitte klicke auf Speichern, dann OK"
|
||||
python $SKILL_DIR/remote.py prompt "Moritz PC" "UAC-Dialog erscheint gleich - bitte bestätigen" --title "Aktion erforderlich"
|
||||
# Ask user to do something (shows MessageBox, blocks until OK)
|
||||
python $SKILL_DIR/remote.py prompt moritz_pc "Please click Save, then OK"
|
||||
python $SKILL_DIR/remote.py prompt moritz_pc "UAC dialog coming - please confirm" --title "Action required"
|
||||
|
||||
# Clipboard
|
||||
python $SKILL_DIR/remote.py clipboard-get "Moritz PC"
|
||||
python $SKILL_DIR/remote.py clipboard-set "Moritz PC" "Text in Zwischenablage"
|
||||
python $SKILL_DIR/remote.py clipboard-get moritz_pc
|
||||
python $SKILL_DIR/remote.py clipboard-set moritz_pc "Text for clipboard"
|
||||
|
||||
# Dateien hoch-/runterladen
|
||||
python $SKILL_DIR/remote.py upload "Moritz PC" /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt"
|
||||
python $SKILL_DIR/remote.py download "Moritz PC" "C:\Users\Moritz\file.txt" /tmp/downloaded.txt
|
||||
# File transfer
|
||||
python $SKILL_DIR/remote.py upload moritz_pc /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt"
|
||||
python $SKILL_DIR/remote.py download moritz_pc "C:\Users\Moritz\file.txt" /tmp/downloaded.txt
|
||||
|
||||
# Label setzen
|
||||
python $SKILL_DIR/remote.py label "Moritz PC" "Neues Label"
|
||||
# Version: compare relay + remote.py + client commits (are they in sync?)
|
||||
python $SKILL_DIR/remote.py version moritz_pc
|
||||
|
||||
# Client log (last 100 lines, --lines for more)
|
||||
python $SKILL_DIR/remote.py logs moritz_pc
|
||||
python $SKILL_DIR/remote.py logs moritz_pc --lines 200
|
||||
```
|
||||
|
||||
## Typischer Workflow: UI-Aufgabe
|
||||
## Typical Workflow: UI Task
|
||||
|
||||
1. `screenshot` → Bildschirm anschauen
|
||||
2. `find-window` → Fenster-ID holen
|
||||
3. `focus` → Fenster in den Vordergrund
|
||||
4. `exec` / `click` / `type` → Aktion ausführen
|
||||
5. `screenshot` → Ergebnis prüfen
|
||||
1. `screenshot <device> screen` → look at the screen
|
||||
2. `windows <device>` → find the window label
|
||||
3. `focus <device> <window_label>` → bring it to front
|
||||
4. `exec` → perform the action
|
||||
5. `screenshot <device> <window_label>` → verify result
|
||||
|
||||
# Warten bis ein Fenster erscheint (z.B. nach Programmstart)
|
||||
python $SKILL_DIR/remote.py wait-for-window "Moritz PC" "notepad" --timeout 10
|
||||
## ⚠️ Prompt Rule (important!)
|
||||
|
||||
# Status: Relay + remote.py + Client-Commit vergleichen (sind alle in sync?)
|
||||
python $SKILL_DIR/remote.py status "Moritz PC"
|
||||
|
||||
# Client-Log (letzte 100 Zeilen, --lines für mehr)
|
||||
python $SKILL_DIR/remote.py logs "Moritz PC"
|
||||
python $SKILL_DIR/remote.py logs "Moritz PC" --lines 200
|
||||
```
|
||||
|
||||
## ⚠️ Klick-Regel (wichtig!)
|
||||
|
||||
**Niemals blind klicken.** Pixel-Koordinaten aus Screenshots sind unzuverlässig.
|
||||
|
||||
Wenn ich auf einen Button oder UI-Element klicken muss:
|
||||
1. Erst `prompt` benutzen um Moritz zu bitten es selbst zu klicken
|
||||
2. Dann weitermachen sobald er OK drückt
|
||||
**Never interact with UI blindly.** When you need the user to click something:
|
||||
|
||||
```bash
|
||||
python $SKILL_DIR/remote.py prompt "Moritz PC" "Bitte klicke auf [Speichern], dann OK drücken"
|
||||
python $SKILL_DIR/remote.py prompt moritz_pc "Please click [Save], then press OK"
|
||||
```
|
||||
|
||||
Ausnahme: wenn ich die exakten Koordinaten eines Elements kenne (z.B. durch wiederholte Nutzung desselben UIs).
|
||||
This blocks until the user confirms. Use it whenever manual interaction is needed.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ base64 = "0.22"
|
|||
png = "0.17"
|
||||
futures-util = "0.3"
|
||||
colored = "2"
|
||||
scopeguard = "1"
|
||||
terminal_size = "0.3"
|
||||
unicode-width = "0.1"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,40 @@
|
|||
# helios-client (Phase 2 — not yet implemented)
|
||||
# helios-client
|
||||
|
||||
This crate will contain the Windows remote-control client for `helios-remote`.
|
||||
Windows client for helios-remote. Connects to the relay server via WebSocket and executes commands.
|
||||
|
||||
## Planned Features
|
||||
## Features
|
||||
|
||||
- Connects to the relay server via WebSocket (`wss://`)
|
||||
- Sends a `Hello` message on connect with an optional display label
|
||||
- Handles incoming `ServerMessage` commands:
|
||||
- `ScreenshotRequest` → captures the primary display (Windows GDI or `windows-capture`) and responds with base64 PNG
|
||||
- `ExecRequest` → runs a shell command in a persistent `cmd.exe` / PowerShell session and returns stdout/stderr/exit-code
|
||||
- `ClickRequest` → simulates a mouse click via `SendInput` Win32 API
|
||||
- `TypeRequest` → types text via `SendInput` (virtual key events)
|
||||
- Persistent shell session so `cd C:\Users` persists across `exec` calls
|
||||
- Auto-reconnect with exponential backoff
|
||||
- Configurable via environment variables or a `client.toml` config file
|
||||
- Full-screen and per-window screenshots
|
||||
- Shell command execution (persistent PowerShell session)
|
||||
- Window management (list, focus, maximize, minimize)
|
||||
- File upload/download
|
||||
- Clipboard get/set
|
||||
- Program launch (fire-and-forget)
|
||||
- User prompts (MessageBox)
|
||||
- Single instance enforcement (PID lock file)
|
||||
|
||||
## Planned Tech Stack
|
||||
## Configuration
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `tokio` | Async runtime |
|
||||
| `tokio-tungstenite` | WebSocket client |
|
||||
| `serde_json` | Protocol serialization |
|
||||
| `windows` / `winapi` | Screen capture, mouse/keyboard input |
|
||||
| `base64` | PNG encoding for screenshots |
|
||||
On first run, the client prompts for:
|
||||
- **Relay URL** (default: `wss://remote.agent-helios.me/ws`)
|
||||
- **API Key**
|
||||
- **Device label** — must be lowercase, no whitespace, only `a-z 0-9 - _`
|
||||
|
||||
## Build Target
|
||||
Config is saved to `%APPDATA%/helios-remote/config.toml`.
|
||||
|
||||
## Device Labels
|
||||
|
||||
The device label is the sole identifier for this machine. It must follow these rules:
|
||||
- Lowercase only
|
||||
- No whitespace
|
||||
- Only characters: `a-z`, `0-9`, `-`, `_`
|
||||
|
||||
Examples: `moritz_pc`, `work-desktop`, `gaming-rig`
|
||||
|
||||
If an existing config has an invalid label, it will be automatically migrated on next startup.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build -p helios-client --release
|
||||
```
|
||||
cargo build --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
## App Icon
|
||||
|
||||
The file `assets/logo.ico` in the repository root is the application icon intended for the Windows `.exe`. It can be embedded at compile time using a build script (e.g. via the `winres` crate).
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -11,27 +11,23 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con
|
|||
|
||||
use base64::Engine;
|
||||
use helios_common::{ClientMessage, ServerMessage};
|
||||
use uuid::Uuid;
|
||||
use helios_common::protocol::{is_valid_label, sanitize_label};
|
||||
|
||||
mod display;
|
||||
mod logger;
|
||||
mod shell;
|
||||
mod screenshot;
|
||||
mod input;
|
||||
mod windows_mgmt;
|
||||
|
||||
// Re-export trunc for use in this file
|
||||
use display::trunc;
|
||||
|
||||
fn banner() {
|
||||
println!();
|
||||
// Use same column layout as info_line: 2sp + emoji_cell(2w) + 2sp + name(14) + 2sp + value
|
||||
// ☀ is 1-wide → emoji_cell pads to 2 → need 1 extra space to match
|
||||
println!(" {} {}", "☀ ".yellow().bold(), "HELIOS REMOTE".bold());
|
||||
display::info_line("🔗", "commit:", &env!("GIT_COMMIT").dimmed().to_string());
|
||||
}
|
||||
|
||||
fn print_session_info(label: &str, sid: &uuid::Uuid) {
|
||||
fn print_device_info(label: &str) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let admin = is_admin();
|
||||
|
|
@ -46,7 +42,6 @@ fn print_session_info(label: &str, sid: &uuid::Uuid) {
|
|||
display::info_line("👤", "privileges:", &"no admin".dimmed().to_string());
|
||||
|
||||
display::info_line("🖥", "device:", &label.dimmed().to_string());
|
||||
display::info_line("🪪", "session:", &sid.to_string().dimmed().to_string());
|
||||
println!();
|
||||
}
|
||||
|
||||
|
|
@ -72,14 +67,69 @@ fn enable_ansi() {
|
|||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ── Single instance enforcement ─────────────────────────────────────────────
|
||||
|
||||
fn lock_file_path() -> PathBuf {
|
||||
let base = dirs::config_dir()
|
||||
.or_else(|| dirs::home_dir())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("helios-remote").join("instance.lock")
|
||||
}
|
||||
|
||||
/// Try to acquire a single-instance lock. Returns true if we got it.
|
||||
fn acquire_instance_lock() -> bool {
|
||||
let path = lock_file_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// Check if another instance is running
|
||||
if path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
// Check if process is still alive
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
let alive = unsafe {
|
||||
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).is_ok()
|
||||
};
|
||||
if alive {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
use std::process::Command;
|
||||
let alive = Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if alive {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write our PID
|
||||
let pid = std::process::id();
|
||||
std::fs::write(&path, pid.to_string()).is_ok()
|
||||
}
|
||||
|
||||
fn release_instance_lock() {
|
||||
let _ = std::fs::remove_file(lock_file_path());
|
||||
}
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Config {
|
||||
relay_url: String,
|
||||
api_key: String,
|
||||
label: Option<String>,
|
||||
session_id: Option<String>, // persistent UUID
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -131,39 +181,73 @@ fn prompt_config() -> Config {
|
|||
};
|
||||
|
||||
let label = {
|
||||
let default_label = hostname();
|
||||
print!(" {} Label for this PC [{}]: ", "→".cyan().bold(), default_label);
|
||||
let default_label = sanitize_label(&hostname());
|
||||
loop {
|
||||
print!(" {} Device label [{}]: ", "→".cyan().bold(), default_label);
|
||||
std::io::stdout().flush().unwrap();
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
Some(default_label)
|
||||
let trimmed = input.trim();
|
||||
let candidate = if trimmed.is_empty() {
|
||||
default_label.clone()
|
||||
} else {
|
||||
Some(trimmed)
|
||||
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, api_key, label, session_id: None }
|
||||
Config { relay_url, api_key, label }
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Enable ANSI color codes on Windows (required when running as admin)
|
||||
#[cfg(windows)]
|
||||
enable_ansi();
|
||||
logger::init();
|
||||
|
||||
// Suppress tracing output by default
|
||||
if std::env::var("RUST_LOG").is_err() {
|
||||
unsafe { std::env::set_var("RUST_LOG", "off"); }
|
||||
}
|
||||
|
||||
banner();
|
||||
|
||||
// Single instance check
|
||||
if !acquire_instance_lock() {
|
||||
display::err("❌", "Another instance of helios-remote is already running.");
|
||||
display::err("", "Only one instance per device is allowed.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Clean up lock on exit
|
||||
let _guard = scopeguard::guard((), |_| release_instance_lock());
|
||||
|
||||
// Load or prompt for config
|
||||
let config = match Config::load() {
|
||||
Some(c) => c,
|
||||
Some(c) => {
|
||||
// Validate existing label
|
||||
if !is_valid_label(&c.label) {
|
||||
let new_label = sanitize_label(&c.label);
|
||||
display::info_line("⚠", "migrate:", &format!(
|
||||
"Label '{}' is invalid, migrating to '{}'", c.label, new_label
|
||||
));
|
||||
let mut cfg = c;
|
||||
cfg.label = new_label;
|
||||
if let Err(e) = cfg.save() {
|
||||
display::err("❌", &format!("Failed to save config: {e}"));
|
||||
}
|
||||
cfg
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
None => {
|
||||
display::info_line("ℹ", "setup:", "No config found — first-time setup");
|
||||
println!();
|
||||
|
|
@ -178,22 +262,8 @@ async fn main() {
|
|||
}
|
||||
};
|
||||
|
||||
// Resolve or generate persistent session UUID
|
||||
let sid: Uuid = match &config.session_id {
|
||||
Some(id) => Uuid::parse_str(id).unwrap_or_else(|_| Uuid::new_v4()),
|
||||
None => {
|
||||
let id = Uuid::new_v4();
|
||||
let mut cfg = config.clone();
|
||||
cfg.session_id = Some(id.to_string());
|
||||
if let Err(e) = cfg.save() {
|
||||
display::err("❌", &format!("Failed to save session_id: {e}"));
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let label = config.label.clone().unwrap_or_else(|| hostname());
|
||||
print_session_info(&label, &sid);
|
||||
let label = config.label.clone();
|
||||
print_device_info(&label);
|
||||
|
||||
let config = Arc::new(config);
|
||||
let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
|
||||
|
|
@ -225,9 +295,9 @@ async fn main() {
|
|||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Send Hello
|
||||
// Send Hello with device label
|
||||
let hello = ClientMessage::Hello {
|
||||
label: config.label.clone(),
|
||||
label: label.clone(),
|
||||
};
|
||||
let hello_json = serde_json::to_string(&hello).unwrap();
|
||||
if let Err(e) = write.send(Message::Text(hello_json)).await {
|
||||
|
|
@ -254,9 +324,6 @@ async fn main() {
|
|||
let shell_clone = Arc::clone(&shell);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// tokio isolates panics per task — a panic here won't kill
|
||||
// the main loop. handle_message uses map_err everywhere so
|
||||
// it should never panic in practice.
|
||||
let response = handle_message(server_msg, shell_clone).await;
|
||||
let json = match serde_json::to_string(&response) {
|
||||
Ok(j) => j,
|
||||
|
|
@ -343,14 +410,14 @@ async fn handle_message(
|
|||
}
|
||||
|
||||
ServerMessage::ScreenshotRequest { request_id } => {
|
||||
display::cmd_start("📷", "screenshot", "");
|
||||
display::cmd_start("📷", "screenshot", "screen");
|
||||
match screenshot::take_screenshot() {
|
||||
Ok((image_base64, width, height)) => {
|
||||
display::cmd_done("📷", "screenshot", "", true, &format!("{width}×{height}"));
|
||||
display::cmd_done("📷", "screenshot", "screen", true, &format!("{width}×{height}"));
|
||||
ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
|
||||
}
|
||||
Err(e) => {
|
||||
display::cmd_done("📷", "screenshot", "", false, &format!("{e}"));
|
||||
display::cmd_done("📷", "screenshot", "screen", false, &format!("{e}"));
|
||||
ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") }
|
||||
}
|
||||
}
|
||||
|
|
@ -358,7 +425,6 @@ async fn handle_message(
|
|||
|
||||
ServerMessage::PromptRequest { request_id, message, title: _ } => {
|
||||
display::prompt_waiting(&message);
|
||||
// Read user input from stdin (blocking)
|
||||
let answer = tokio::task::spawn_blocking(|| {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).ok();
|
||||
|
|
@ -375,8 +441,6 @@ async fn handle_message(
|
|||
match sh.run(&command, timeout_ms).await {
|
||||
Ok((stdout, stderr, exit_code)) => {
|
||||
let result = if exit_code != 0 {
|
||||
// For errors: use first non-empty stderr line (actual error message),
|
||||
// ignoring PowerShell boilerplate like "+ CategoryInfo", "In Zeile:", etc.
|
||||
let err_line = stderr.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty()
|
||||
|
|
@ -388,7 +452,6 @@ async fn handle_message(
|
|||
.to_string();
|
||||
err_line
|
||||
} else {
|
||||
// Success: first stdout line, no exit code
|
||||
stdout.trim().lines().next().unwrap_or("").to_string()
|
||||
};
|
||||
display::cmd_done("⚡", "execute", &payload, exit_code == 0, &result);
|
||||
|
|
@ -401,36 +464,6 @@ async fn handle_message(
|
|||
}
|
||||
}
|
||||
|
||||
ServerMessage::ClickRequest { request_id, x, y, button } => {
|
||||
let payload = format!("({x}, {y}) {button:?}");
|
||||
display::cmd_start("🖱", "click", &payload);
|
||||
match input::click(x, y, &button) {
|
||||
Ok(()) => {
|
||||
display::cmd_done("🖱", "click", &payload, true, "done");
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
Err(e) => {
|
||||
display::cmd_done("🖱", "click", &payload, false, &format!("{e}"));
|
||||
ClientMessage::Error { request_id, message: format!("Click at ({x},{y}) failed: {e}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::TypeRequest { request_id, text } => {
|
||||
let payload = format!("{} chars", text.len());
|
||||
display::cmd_start("⌨", "type", &payload);
|
||||
match input::type_text(&text) {
|
||||
Ok(()) => {
|
||||
display::cmd_done("⌨", "type", &payload, true, "done");
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
Err(e) => {
|
||||
display::cmd_done("⌨", "type", &payload, false, &format!("{e}"));
|
||||
ClientMessage::Error { request_id, message: format!("Type failed: {e}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ListWindowsRequest { request_id } => {
|
||||
display::cmd_start("🪟", "list windows", "");
|
||||
match windows_mgmt::list_windows() {
|
||||
|
|
@ -610,7 +643,7 @@ async fn handle_message(
|
|||
if let Some(rid) = request_id {
|
||||
ClientMessage::Ack { request_id: rid }
|
||||
} else {
|
||||
ClientMessage::Hello { label: None }
|
||||
ClientMessage::Hello { label: String::new() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use helios_common::protocol::WindowInfo;
|
||||
use helios_common::protocol::{sanitize_label, WindowInfo};
|
||||
|
||||
// ── Windows implementation ──────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -14,7 +14,6 @@ mod win_impl {
|
|||
keybd_event, KEYEVENTF_KEYUP, VK_MENU,
|
||||
};
|
||||
|
||||
// Collect HWNDs via EnumWindows callback
|
||||
unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let list = &mut *(lparam.0 as *mut Vec<HWND>);
|
||||
list.push(hwnd);
|
||||
|
|
@ -38,19 +37,29 @@ mod win_impl {
|
|||
String::from_utf16_lossy(&buf[..len as usize])
|
||||
}
|
||||
|
||||
/// Generate a human-readable label from a window title.
|
||||
/// E.g. "Google Chrome" -> "google_chrome", "Discord" -> "discord"
|
||||
fn window_label(title: &str) -> String {
|
||||
sanitize_label(title)
|
||||
}
|
||||
|
||||
pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
|
||||
let hwnds = get_all_hwnds();
|
||||
let mut windows = Vec::new();
|
||||
for hwnd in hwnds {
|
||||
let visible = unsafe { IsWindowVisible(hwnd).as_bool() };
|
||||
let title = hwnd_title(hwnd);
|
||||
// Only return visible windows with a non-empty title
|
||||
if !visible || title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let label = window_label(&title);
|
||||
if label.is_empty() {
|
||||
continue;
|
||||
}
|
||||
windows.push(WindowInfo {
|
||||
id: hwnd.0 as u64,
|
||||
title,
|
||||
label,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -71,9 +80,6 @@ mod win_impl {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Bypass Windows Focus Stealing Prevention by sending a fake Alt keypress
|
||||
/// before calling SetForegroundWindow. Without this, SetForegroundWindow
|
||||
/// silently fails when the calling thread is not in the foreground.
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,27 +1,58 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Information about a single window on the client machine
|
||||
/// Information about a single window on the client machine.
|
||||
/// `label` is a human-readable, lowercase identifier (e.g. "google_chrome", "discord").
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowInfo {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub label: String,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// Validate a device/window label: lowercase, no whitespace, only a-z 0-9 - _
|
||||
pub fn is_valid_label(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Convert an arbitrary string into a valid label.
|
||||
/// Lowercase, replace whitespace and invalid chars with '_', collapse runs.
|
||||
pub fn sanitize_label(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut prev_underscore = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
result.push(c.to_ascii_lowercase());
|
||||
prev_underscore = false;
|
||||
} else if c == '-' {
|
||||
result.push('-');
|
||||
prev_underscore = false;
|
||||
} else {
|
||||
// Replace whitespace and other chars with _
|
||||
if !prev_underscore && !result.is_empty() {
|
||||
result.push('_');
|
||||
prev_underscore = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trim trailing _
|
||||
result.trim_end_matches('_').to_string()
|
||||
}
|
||||
|
||||
/// Messages sent from the relay server to a connected client
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
/// Request a screenshot from the client
|
||||
/// Request a full-screen screenshot
|
||||
ScreenshotRequest { request_id: Uuid },
|
||||
/// Capture a specific window by its HWND (works even if behind other windows)
|
||||
/// 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.
|
||||
/// Blocks until the user clicks OK — use this when you need the user
|
||||
/// to perform a manual action before continuing.
|
||||
/// Show a MessageBox on the client asking the user to do something
|
||||
PromptRequest {
|
||||
request_id: Uuid,
|
||||
message: String,
|
||||
|
|
@ -31,21 +62,8 @@ pub enum ServerMessage {
|
|||
ExecRequest {
|
||||
request_id: Uuid,
|
||||
command: String,
|
||||
/// Timeout in milliseconds. None = use client default (30s)
|
||||
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
|
||||
Ack { request_id: Uuid },
|
||||
/// Server-side error response
|
||||
|
|
@ -90,8 +108,8 @@ pub enum ServerMessage {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
/// Client registers itself with optional display name
|
||||
Hello { label: Option<String> },
|
||||
/// Client registers itself with its device label
|
||||
Hello { label: String },
|
||||
/// Response to a screenshot request — base64-encoded PNG
|
||||
ScreenshotResponse {
|
||||
request_id: Uuid,
|
||||
|
|
@ -106,7 +124,7 @@ pub enum ClientMessage {
|
|||
stderr: String,
|
||||
exit_code: i32,
|
||||
},
|
||||
/// Generic acknowledgement for click/type/minimize-all/focus/maximize
|
||||
/// Generic acknowledgement
|
||||
Ack { request_id: Uuid },
|
||||
/// Client error response
|
||||
Error {
|
||||
|
|
@ -137,29 +155,33 @@ pub enum ClientMessage {
|
|||
},
|
||||
/// Response to a clipboard-get request
|
||||
ClipboardGetResponse { request_id: Uuid, text: String },
|
||||
/// Response to a prompt request — contains the user's typed answer
|
||||
/// Response to a prompt request
|
||||
PromptResponse { request_id: Uuid, answer: String },
|
||||
}
|
||||
|
||||
/// Mouse button variants
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
impl Default for MouseButton {
|
||||
fn default() -> Self {
|
||||
MouseButton::Left
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_labels() {
|
||||
assert!(is_valid_label("moritz_pc"));
|
||||
assert!(is_valid_label("my-desktop"));
|
||||
assert!(is_valid_label("pc01"));
|
||||
assert!(!is_valid_label("Moritz PC"));
|
||||
assert!(!is_valid_label(""));
|
||||
assert!(!is_valid_label("has spaces"));
|
||||
assert!(!is_valid_label("UPPER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_label() {
|
||||
assert_eq!(sanitize_label("Moritz PC"), "moritz_pc");
|
||||
assert_eq!(sanitize_label("My Desktop!!"), "my_desktop");
|
||||
assert_eq!(sanitize_label("hello-world"), "hello-world");
|
||||
assert_eq!(sanitize_label("DESKTOP-ABC123"), "desktop-abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_message_serialization() {
|
||||
let msg = ServerMessage::ExecRequest {
|
||||
|
|
@ -174,25 +196,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_client_message_serialization() {
|
||||
let msg = ClientMessage::Hello { label: Some("test-pc".into()) };
|
||||
let msg = ClientMessage::Hello { label: "test-pc".into() };
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert!(json.contains("hello"));
|
||||
assert!(json.contains("test-pc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let msg = ClientMessage::ExecResponse {
|
||||
request_id: Uuid::nil(),
|
||||
stdout: "hello\n".into(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
let decoded: ClientMessage = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
ClientMessage::ExecResponse { exit_code, .. } => assert_eq!(exit_code, 0),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||
use uuid::Uuid;
|
||||
use tracing::error;
|
||||
|
||||
use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage};
|
||||
use helios_common::protocol::{ClientMessage, ServerMessage};
|
||||
use crate::AppState;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
|
@ -21,33 +21,29 @@ pub struct ErrorBody {
|
|||
pub error: String,
|
||||
}
|
||||
|
||||
fn not_found(session_id: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
fn not_found(label: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorBody {
|
||||
error: format!("Session '{session_id}' not found or not connected"),
|
||||
error: format!("Device '{label}' not found or not connected"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn timeout_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
fn timeout_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorBody {
|
||||
error: format!(
|
||||
"Timed out waiting for client response (session='{session_id}', op='{op}')"
|
||||
),
|
||||
error: format!("Timed out waiting for client response (device='{label}', op='{op}')"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
fn send_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(ErrorBody {
|
||||
error: format!(
|
||||
"Failed to send command to client — client may have disconnected (session='{session_id}', op='{op}')"
|
||||
),
|
||||
error: format!("Failed to send command to client — may have disconnected (device='{label}', op='{op}')"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -56,19 +52,19 @@ fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
|
|||
|
||||
async fn dispatch<F>(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
label: &str,
|
||||
op: &str,
|
||||
make_msg: F,
|
||||
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
|
||||
where
|
||||
F: FnOnce(Uuid) -> ServerMessage,
|
||||
{
|
||||
dispatch_with_timeout(state, session_id, op, make_msg, REQUEST_TIMEOUT).await
|
||||
dispatch_with_timeout(state, label, op, make_msg, REQUEST_TIMEOUT).await
|
||||
}
|
||||
|
||||
async fn dispatch_with_timeout<F>(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
label: &str,
|
||||
op: &str,
|
||||
make_msg: F,
|
||||
timeout: Duration,
|
||||
|
|
@ -76,50 +72,62 @@ async fn dispatch_with_timeout<F>(
|
|||
where
|
||||
F: FnOnce(Uuid) -> ServerMessage,
|
||||
{
|
||||
let id = session_id.parse::<Uuid>().map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorBody {
|
||||
error: format!("Invalid session id: '{session_id}'"),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let tx = state
|
||||
.sessions
|
||||
.get_cmd_tx(&id)
|
||||
.ok_or_else(|| not_found(session_id))?;
|
||||
.get_cmd_tx(label)
|
||||
.ok_or_else(|| not_found(label))?;
|
||||
|
||||
let request_id = Uuid::new_v4();
|
||||
let rx = state.sessions.register_pending(request_id);
|
||||
let msg = make_msg(request_id);
|
||||
|
||||
tx.send(msg).await.map_err(|e| {
|
||||
error!("Channel send failed for session={session_id}, op={op}: {e}");
|
||||
send_error(session_id, op)
|
||||
error!("Channel send failed for device={label}, op={op}: {e}");
|
||||
send_error(label, op)
|
||||
})?;
|
||||
|
||||
match tokio::time::timeout(timeout, rx).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(_)) => Err(send_error(session_id, op)),
|
||||
Err(_) => Err(timeout_error(session_id, op)),
|
||||
Ok(Err(_)) => Err(send_error(label, op)),
|
||||
Err(_) => Err(timeout_error(label, op)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /sessions — list all connected clients
|
||||
pub async fn list_sessions(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let sessions = state.sessions.list();
|
||||
Json(serde_json::json!({ "sessions": sessions }))
|
||||
/// GET /devices — list all connected clients
|
||||
pub async fn list_devices(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let devices = state.sessions.list();
|
||||
Json(serde_json::json!({ "devices": devices }))
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/:window_id/screenshot
|
||||
pub async fn window_screenshot(
|
||||
Path((session_id, window_id)): Path<(String, u64)>,
|
||||
/// POST /devices/:label/screenshot — full screen screenshot
|
||||
pub async fn request_screenshot(
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "window_screenshot", |rid| {
|
||||
match dispatch(&state, &label, "screenshot", |rid| {
|
||||
ServerMessage::ScreenshotRequest { request_id: rid }
|
||||
}).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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, .. }) => (
|
||||
|
|
@ -135,14 +143,14 @@ pub async fn window_screenshot(
|
|||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/logs?lines=100
|
||||
/// GET /devices/:label/logs?lines=100
|
||||
pub async fn logs(
|
||||
Path(session_id): Path<String>,
|
||||
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, &session_id, "logs", |rid| {
|
||||
match dispatch(&state, &label, "logs", |rid| {
|
||||
ServerMessage::LogsRequest { request_id: rid, lines }
|
||||
}).await {
|
||||
Ok(ClientMessage::LogsResponse { content, log_path, .. }) => (
|
||||
|
|
@ -158,216 +166,95 @@ pub async fn logs(
|
|||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/screenshot
|
||||
pub async fn request_screenshot(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "screenshot", |rid| {
|
||||
ServerMessage::ScreenshotRequest { request_id: rid }
|
||||
})
|
||||
.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 from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/exec
|
||||
/// POST /devices/:label/exec
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExecBody {
|
||||
pub command: String,
|
||||
/// Optional timeout in milliseconds (default: 30000). Use higher values for
|
||||
/// long-running commands like downloads.
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn request_exec(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ExecBody>,
|
||||
) -> impl IntoResponse {
|
||||
// Server-side wait must be at least as long as the client timeout + buffer
|
||||
let server_timeout = body.timeout_ms
|
||||
.map(|ms| std::time::Duration::from_millis(ms + 5_000))
|
||||
.map(|ms| Duration::from_millis(ms + 5_000))
|
||||
.unwrap_or(REQUEST_TIMEOUT);
|
||||
|
||||
match dispatch_with_timeout(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest {
|
||||
match dispatch_with_timeout(&state, &label, "exec", |rid| ServerMessage::ExecRequest {
|
||||
request_id: rid,
|
||||
command: body.command.clone(),
|
||||
timeout_ms: body.timeout_ms,
|
||||
}, server_timeout)
|
||||
.await
|
||||
{
|
||||
Ok(ClientMessage::ExecResponse {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
..
|
||||
}) => (
|
||||
}, server_timeout).await {
|
||||
Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Json(serde_json::json!({ "stdout": stdout, "stderr": stderr, "exit_code": exit_code })),
|
||||
).into_response(),
|
||||
Ok(ClientMessage::Error { message, .. }) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": message })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/click
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClickBody {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
#[serde(default)]
|
||||
pub button: MouseButton,
|
||||
}
|
||||
|
||||
pub async fn request_click(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ClickBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "click", |rid| ServerMessage::ClickRequest {
|
||||
request_id: rid,
|
||||
x: body.x,
|
||||
y: body.y,
|
||||
button: body.button.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/type
|
||||
#[derive(Deserialize)]
|
||||
pub struct TypeBody {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn request_type(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<TypeBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "type", |rid| ServerMessage::TypeRequest {
|
||||
request_id: rid,
|
||||
text: body.text.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/windows
|
||||
/// GET /devices/:label/windows
|
||||
pub async fn list_windows(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "list_windows", |rid| {
|
||||
match dispatch(&state, &label, "list_windows", |rid| {
|
||||
ServerMessage::ListWindowsRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "windows": windows })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(ClientMessage::Error { message, .. }) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": message })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/minimize-all
|
||||
/// POST /devices/:label/windows/minimize-all
|
||||
pub async fn minimize_all(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "minimize_all", |rid| {
|
||||
match dispatch(&state, &label, "minimize_all", |rid| {
|
||||
ServerMessage::MinimizeAllRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/:window_id/focus
|
||||
/// POST /devices/:label/windows/:window_id/focus
|
||||
pub async fn focus_window(
|
||||
Path((session_id, window_id)): Path<(String, u64)>,
|
||||
Path((label, window_id)): Path<(String, u64)>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "focus_window", |rid| {
|
||||
match dispatch(&state, &label, "focus_window", |rid| {
|
||||
ServerMessage::FocusWindowRequest { request_id: rid, window_id }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/windows/:window_id/maximize
|
||||
/// POST /devices/:label/windows/:window_id/maximize
|
||||
pub async fn maximize_and_focus(
|
||||
Path((session_id, window_id)): Path<(String, u64)>,
|
||||
Path((label, window_id)): Path<(String, u64)>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "maximize_and_focus", |rid| {
|
||||
match dispatch(&state, &label, "maximize_and_focus", |rid| {
|
||||
ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
|
|
@ -376,41 +263,32 @@ pub async fn maximize_and_focus(
|
|||
/// GET /version — server version (public, no auth)
|
||||
pub async fn server_version() -> impl IntoResponse {
|
||||
Json(serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"commit": env!("GIT_COMMIT"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/version — client version
|
||||
/// GET /devices/:label/version — client version
|
||||
pub async fn client_version(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "version", |rid| {
|
||||
match dispatch(&state, &label, "version", |rid| {
|
||||
ServerMessage::VersionRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::VersionResponse { version, commit, .. }) => (
|
||||
StatusCode::OK,
|
||||
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(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/upload
|
||||
/// POST /devices/:label/upload
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadBody {
|
||||
pub path: String,
|
||||
|
|
@ -418,59 +296,49 @@ pub struct UploadBody {
|
|||
}
|
||||
|
||||
pub async fn upload_file(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<UploadBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "upload", |rid| ServerMessage::UploadRequest {
|
||||
match dispatch(&state, &label, "upload", |rid| ServerMessage::UploadRequest {
|
||||
request_id: rid,
|
||||
path: body.path.clone(),
|
||||
content_base64: body.content_base64.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/download?path=...
|
||||
/// GET /devices/:label/download?path=...
|
||||
#[derive(Deserialize)]
|
||||
pub struct DownloadQuery {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub async fn download_file(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<DownloadQuery>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "download", |rid| ServerMessage::DownloadRequest {
|
||||
match dispatch(&state, &label, "download", |rid| ServerMessage::DownloadRequest {
|
||||
request_id: rid,
|
||||
path: query.path.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::DownloadResponse { content_base64, size, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "content_base64": content_base64, "size": size })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(ClientMessage::Error { message, .. }) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": message })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/run
|
||||
/// POST /devices/:label/run
|
||||
#[derive(Deserialize)]
|
||||
pub struct RunBody {
|
||||
pub program: String,
|
||||
|
|
@ -479,73 +347,61 @@ pub struct RunBody {
|
|||
}
|
||||
|
||||
pub async fn run_program(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RunBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "run", |rid| ServerMessage::RunRequest {
|
||||
match dispatch(&state, &label, "run", |rid| ServerMessage::RunRequest {
|
||||
request_id: rid,
|
||||
program: body.program.clone(),
|
||||
args: body.args.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/clipboard
|
||||
/// GET /devices/:label/clipboard
|
||||
pub async fn clipboard_get(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "clipboard_get", |rid| {
|
||||
match dispatch(&state, &label, "clipboard_get", |rid| {
|
||||
ServerMessage::ClipboardGetRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::ClipboardGetResponse { text, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "text": text })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(ClientMessage::Error { message, .. }) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": message })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
).into_response(),
|
||||
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/clipboard
|
||||
/// POST /devices/:label/clipboard
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClipboardSetBody {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn clipboard_set(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ClipboardSetBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "clipboard_set", |rid| {
|
||||
match dispatch(&state, &label, "clipboard_set", |rid| {
|
||||
ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/prompt
|
||||
/// POST /devices/:label/prompt
|
||||
#[derive(Deserialize)]
|
||||
pub struct PromptBody {
|
||||
pub message: String,
|
||||
|
|
@ -553,17 +409,15 @@ pub struct PromptBody {
|
|||
}
|
||||
|
||||
pub async fn prompt_user(
|
||||
Path(session_id): Path<String>,
|
||||
Path(label): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<PromptBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "prompt", |rid| ServerMessage::PromptRequest {
|
||||
match dispatch(&state, &label, "prompt", |rid| ServerMessage::PromptRequest {
|
||||
request_id: rid,
|
||||
message: body.message.clone(),
|
||||
title: body.title.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
}).await {
|
||||
Ok(ClientMessage::PromptResponse { answer, .. }) => {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "answer": answer }))).into_response()
|
||||
}
|
||||
|
|
@ -571,32 +425,3 @@ pub async fn prompt_user(
|
|||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/label
|
||||
#[derive(Deserialize)]
|
||||
pub struct LabelBody {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub async fn set_label(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LabelBody>,
|
||||
) -> impl IntoResponse {
|
||||
let id = match session_id.parse::<Uuid>() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": format!("Invalid session id: '{session_id}'") })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if state.sessions.set_label(&id, body.label.clone()) {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
|
||||
} else {
|
||||
not_found(&session_id).into_response()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
.init();
|
||||
|
||||
const GIT_COMMIT: &str = env!("GIT_COMMIT");
|
||||
info!("helios-server v{} ({})", env!("CARGO_PKG_VERSION"), GIT_COMMIT);
|
||||
info!("helios-server ({GIT_COMMIT})");
|
||||
|
||||
let api_key = std::env::var("HELIOS_API_KEY")
|
||||
.unwrap_or_else(|_| "dev-secret".to_string());
|
||||
|
|
@ -45,25 +45,22 @@ async fn main() -> anyhow::Result<()> {
|
|||
};
|
||||
|
||||
let protected = Router::new()
|
||||
.route("/sessions", get(api::list_sessions))
|
||||
.route("/sessions/:id/screenshot", post(api::request_screenshot))
|
||||
.route("/sessions/:id/exec", post(api::request_exec))
|
||||
.route("/sessions/:id/click", post(api::request_click))
|
||||
.route("/sessions/:id/type", post(api::request_type))
|
||||
.route("/sessions/:id/label", post(api::set_label))
|
||||
.route("/sessions/:id/prompt", post(api::prompt_user))
|
||||
.route("/sessions/:id/windows", get(api::list_windows))
|
||||
.route("/sessions/:id/windows/minimize-all", post(api::minimize_all))
|
||||
.route("/sessions/:id/logs", get(api::logs))
|
||||
.route("/sessions/:id/windows/:window_id/screenshot", post(api::window_screenshot))
|
||||
.route("/sessions/:id/windows/:window_id/focus", post(api::focus_window))
|
||||
.route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus))
|
||||
.route("/sessions/:id/version", get(api::client_version))
|
||||
.route("/sessions/:id/upload", post(api::upload_file))
|
||||
.route("/sessions/:id/download", get(api::download_file))
|
||||
.route("/sessions/:id/run", post(api::run_program))
|
||||
.route("/sessions/:id/clipboard", get(api::clipboard_get))
|
||||
.route("/sessions/:id/clipboard", post(api::clipboard_set))
|
||||
.route("/devices", get(api::list_devices))
|
||||
.route("/devices/:label/screenshot", post(api::request_screenshot))
|
||||
.route("/devices/:label/exec", post(api::request_exec))
|
||||
.route("/devices/:label/prompt", post(api::prompt_user))
|
||||
.route("/devices/:label/windows", get(api::list_windows))
|
||||
.route("/devices/:label/windows/minimize-all", post(api::minimize_all))
|
||||
.route("/devices/:label/logs", get(api::logs))
|
||||
.route("/devices/:label/windows/:window_id/screenshot", post(api::window_screenshot))
|
||||
.route("/devices/:label/windows/:window_id/focus", post(api::focus_window))
|
||||
.route("/devices/:label/windows/:window_id/maximize", post(api::maximize_and_focus))
|
||||
.route("/devices/:label/version", get(api::client_version))
|
||||
.route("/devices/:label/upload", post(api::upload_file))
|
||||
.route("/devices/:label/download", get(api::download_file))
|
||||
.route("/devices/:label/run", post(api::run_program))
|
||||
.route("/devices/:label/clipboard", get(api::clipboard_get))
|
||||
.route("/devices/:label/clipboard", post(api::clipboard_set))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), require_api_key));
|
||||
|
||||
let app = Router::new()
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ use uuid::Uuid;
|
|||
use serde::Serialize;
|
||||
use helios_common::protocol::{ClientMessage, ServerMessage};
|
||||
|
||||
/// Represents one connected remote client
|
||||
/// Represents one connected remote client.
|
||||
/// The device label is the sole identifier — no session UUIDs exposed externally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
pub label: Option<String>,
|
||||
pub label: String,
|
||||
/// Channel to send commands to the WS handler for this session
|
||||
pub cmd_tx: mpsc::Sender<ServerMessage>,
|
||||
}
|
||||
|
|
@ -16,22 +16,20 @@ pub struct Session {
|
|||
/// Serializable view of a session for the REST API
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SessionInfo {
|
||||
pub id: Uuid,
|
||||
pub label: Option<String>,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl From<&Session> for SessionInfo {
|
||||
fn from(s: &Session) -> Self {
|
||||
SessionInfo {
|
||||
id: s.id,
|
||||
label: s.label.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionStore {
|
||||
/// Active sessions by ID
|
||||
sessions: DashMap<Uuid, Session>,
|
||||
/// Active sessions keyed by device label
|
||||
sessions: DashMap<String, Session>,
|
||||
/// Pending request callbacks by request_id
|
||||
pending: DashMap<Uuid, oneshot::Sender<ClientMessage>>,
|
||||
}
|
||||
|
|
@ -45,24 +43,15 @@ impl SessionStore {
|
|||
}
|
||||
|
||||
pub fn insert(&self, session: Session) {
|
||||
self.sessions.insert(session.id, session);
|
||||
self.sessions.insert(session.label.clone(), session);
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &Uuid) {
|
||||
self.sessions.remove(id);
|
||||
pub fn remove(&self, label: &str) {
|
||||
self.sessions.remove(label);
|
||||
}
|
||||
|
||||
pub fn get_cmd_tx(&self, id: &Uuid) -> Option<mpsc::Sender<ServerMessage>> {
|
||||
self.sessions.get(id).map(|s| s.cmd_tx.clone())
|
||||
}
|
||||
|
||||
pub fn set_label(&self, id: &Uuid, label: String) -> bool {
|
||||
if let Some(mut s) = self.sessions.get_mut(id) {
|
||||
s.label = Some(label);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
pub fn get_cmd_tx(&self, label: &str) -> Option<mpsc::Sender<ServerMessage>> {
|
||||
self.sessions.get(label).map(|s| s.cmd_tx.clone())
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<SessionInfo> {
|
||||
|
|
@ -77,7 +66,6 @@ impl SessionStore {
|
|||
}
|
||||
|
||||
/// Deliver a client response to the waiting request handler.
|
||||
/// Returns true if the request was found and resolved.
|
||||
pub fn resolve_pending(&self, request_id: Uuid, msg: ClientMessage) -> bool {
|
||||
if let Some((_, tx)) = self.pending.remove(&request_id) {
|
||||
let _ = tx.send(msg);
|
||||
|
|
|
|||
|
|
@ -19,32 +19,57 @@ pub async fn ws_upgrade(
|
|||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
let session_id = Uuid::new_v4();
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::channel::<ServerMessage>(64);
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Register session (label filled in on Hello)
|
||||
// Wait for the Hello message to get the device label
|
||||
let label = loop {
|
||||
match ws_rx.next().await {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Hello { label }) => {
|
||||
if label.is_empty() {
|
||||
warn!("Client sent empty label, disconnecting");
|
||||
return;
|
||||
}
|
||||
break label;
|
||||
}
|
||||
Ok(_) => {
|
||||
warn!("Expected Hello as first message, got something else");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Invalid JSON on handshake: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => return,
|
||||
_ => continue,
|
||||
}
|
||||
};
|
||||
|
||||
// Register session by label
|
||||
let session = Session {
|
||||
id: session_id,
|
||||
label: None,
|
||||
label: label.clone(),
|
||||
cmd_tx,
|
||||
};
|
||||
state.sessions.insert(session);
|
||||
info!("Client connected: session={session_id}");
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
info!("Client connected: device={label}");
|
||||
|
||||
// Spawn task: forward server commands → WS
|
||||
let label_clone = label.clone();
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(msg) = cmd_rx.recv().await {
|
||||
match serde_json::to_string(&msg) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = ws_tx.send(Message::Text(json.into())).await {
|
||||
error!("WS send error for session={session_id}: {e}");
|
||||
error!("WS send error for device={label_clone}: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Serialization error for session={session_id}: {e}");
|
||||
error!("Serialization error for device={label_clone}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,36 +80,33 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
|||
match result {
|
||||
Ok(Message::Text(text)) => {
|
||||
match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(msg) => handle_client_message(session_id, msg, &state).await,
|
||||
Ok(msg) => handle_client_message(&label, msg, &state).await,
|
||||
Err(e) => {
|
||||
warn!("Invalid JSON from session={session_id}: {e} | raw={text}");
|
||||
warn!("Invalid JSON from device={label}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("Client disconnected gracefully: session={session_id}");
|
||||
info!("Client disconnected gracefully: device={label}");
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {}
|
||||
Err(e) => {
|
||||
error!("WS receive error for session={session_id}: {e}");
|
||||
error!("WS receive error for device={label}: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send_task.abort();
|
||||
state.sessions.remove(&session_id);
|
||||
info!("Session cleaned up: session={session_id}");
|
||||
state.sessions.remove(&label);
|
||||
info!("Session cleaned up: device={label}");
|
||||
}
|
||||
|
||||
async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &AppState) {
|
||||
async fn handle_client_message(label: &str, msg: ClientMessage, state: &AppState) {
|
||||
match &msg {
|
||||
ClientMessage::Hello { label } => {
|
||||
if let Some(lbl) = label {
|
||||
state.sessions.set_label(&session_id, lbl.clone());
|
||||
}
|
||||
debug!("Hello from session={session_id}, label={label:?}");
|
||||
ClientMessage::Hello { .. } => {
|
||||
debug!("Duplicate Hello from device={label}, ignoring");
|
||||
}
|
||||
ClientMessage::ScreenshotResponse { request_id, .. }
|
||||
| ClientMessage::ExecResponse { request_id, .. }
|
||||
|
|
@ -98,7 +120,7 @@ async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &App
|
|||
| ClientMessage::Error { request_id, .. } => {
|
||||
let rid = *request_id;
|
||||
if !state.sessions.resolve_pending(rid, msg) {
|
||||
warn!("No pending request for request_id={rid} (session={session_id})");
|
||||
warn!("No pending request for request_id={rid} (device={label})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
499
remote.py
499
remote.py
|
|
@ -1,12 +1,15 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
helios-remote - CLI to control PCs connected to the Helios Remote Relay Server.
|
||||
Config is loaded from config.env in the same directory as this script.
|
||||
helios-remote CLI - Control devices connected to the Helios Remote Relay Server.
|
||||
|
||||
Device labels are the sole identifiers. No session UUIDs.
|
||||
Labels must be lowercase, no whitespace, only a-z 0-9 - _
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -32,6 +35,18 @@ except ImportError:
|
|||
sys.exit("[helios-remote] ERROR: 'requests' not installed. Run: pip install requests")
|
||||
|
||||
|
||||
LABEL_RE = re.compile(r'^[a-z0-9][a-z0-9_-]*$')
|
||||
|
||||
def validate_label(label: str) -> str:
|
||||
"""Validate a device label."""
|
||||
if not LABEL_RE.match(label):
|
||||
sys.exit(
|
||||
f"[helios-remote] Invalid label '{label}'. "
|
||||
f"Must be lowercase, no whitespace, only a-z 0-9 - _"
|
||||
)
|
||||
return label
|
||||
|
||||
|
||||
# ── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _headers() -> dict:
|
||||
|
|
@ -58,111 +73,85 @@ def _req(method: str, path: str, timeout: int = 30, **kwargs):
|
|||
return resp
|
||||
|
||||
|
||||
# ── Subcommands ──────────────────────────────────────────────────────────────
|
||||
# ── Window resolution ────────────────────────────────────────────────────────
|
||||
|
||||
HEADERS = _headers # alias for lazy evaluation
|
||||
|
||||
|
||||
def resolve_session(session_id: str) -> str:
|
||||
"""If session_id looks like a label (not a UUID), resolve it to an actual UUID."""
|
||||
import re
|
||||
uuid_pattern = re.compile(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I
|
||||
)
|
||||
if uuid_pattern.match(session_id):
|
||||
return session_id
|
||||
# Look up by label
|
||||
resp = _req("GET", "/sessions")
|
||||
sessions = resp.json().get("sessions", [])
|
||||
for s in sessions:
|
||||
if s.get("label", "").lower() == session_id.lower():
|
||||
return s["id"]
|
||||
raise SystemExit(f"[helios-remote] No session found with label '{session_id}'")
|
||||
|
||||
|
||||
def resolve_window(sid: str, window_id_or_name: str) -> int:
|
||||
"""If window_id_or_name is a number, return it. Otherwise search by title substring."""
|
||||
if window_id_or_name.lstrip('-').isdigit():
|
||||
return int(window_id_or_name)
|
||||
# Search by title
|
||||
resp = _req("GET", f"/sessions/{sid}/windows")
|
||||
def resolve_window(device: str, window_label: str) -> int:
|
||||
"""Resolve a window label to its HWND. Searches by label field."""
|
||||
resp = _req("GET", f"/devices/{device}/windows")
|
||||
windows = resp.json().get("windows", [])
|
||||
query = window_id_or_name.lower()
|
||||
query = window_label.lower()
|
||||
# Exact label match first
|
||||
for w in windows:
|
||||
if w.get("visible") and w.get("label", "") == query:
|
||||
return int(w["id"])
|
||||
# Substring match on label
|
||||
matches = [w for w in windows if w.get("visible") and query in w.get("label", "")]
|
||||
if not matches:
|
||||
# Fallback: substring match on title
|
||||
matches = [w for w in windows if w.get("visible") and query in w.get("title", "").lower()]
|
||||
if not matches:
|
||||
raise SystemExit(f"[helios-remote] No visible window matching '{window_id_or_name}'")
|
||||
raise SystemExit(f"[helios-remote] No visible window matching '{window_label}'")
|
||||
if len(matches) > 1:
|
||||
print(f"[helios-remote] Multiple matches for '{window_id_or_name}', using first:")
|
||||
print(f"[helios-remote] Multiple matches for '{window_label}', using first:")
|
||||
for w in matches:
|
||||
print(f" {w['id']} {w['title']}")
|
||||
print(f" {w.get('label', '?'):<30} {w['title']}")
|
||||
return int(matches[0]["id"])
|
||||
|
||||
|
||||
def cmd_sessions(_args):
|
||||
"""List all connected sessions."""
|
||||
resp = _req("GET", "/sessions")
|
||||
# ── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_devices(_args):
|
||||
"""List all connected devices."""
|
||||
resp = _req("GET", "/devices")
|
||||
data = resp.json()
|
||||
sessions = data if isinstance(data, list) else data.get("sessions", [])
|
||||
|
||||
if not sessions:
|
||||
print("No sessions connected.")
|
||||
devices = data.get("devices", [])
|
||||
if not devices:
|
||||
print("No devices connected.")
|
||||
return
|
||||
|
||||
print(f"{'ID':<36} {'Label':<20} Info")
|
||||
print("-" * 70)
|
||||
for s in sessions:
|
||||
sid = str(s.get("id") or s.get("session_id") or "?")
|
||||
label = str(s.get("label") or s.get("name") or "")
|
||||
info = str(s.get("os") or s.get("platform") or s.get("info") or "")
|
||||
print(f"{sid:<36} {label:<20} {info}")
|
||||
|
||||
|
||||
def cmd_label(args):
|
||||
"""Assign a human-readable name to a session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
_req("POST", f"/sessions/{sid}/label", json={"label": args.name})
|
||||
print(f"Session {sid!r} labeled as {args.name!r}.")
|
||||
print(f"{'Device':<30}")
|
||||
print("-" * 30)
|
||||
for d in devices:
|
||||
print(d.get("label", "?"))
|
||||
|
||||
|
||||
def cmd_screenshot(args):
|
||||
"""Capture a screenshot → /tmp/helios-remote-screenshot.png"""
|
||||
sid = resolve_session(args.session_id)
|
||||
resp = _req("POST", f"/sessions/{sid}/screenshot")
|
||||
"""Capture a screenshot. Argument is 'screen' for full screen or a window label."""
|
||||
device = validate_label(args.device)
|
||||
target = args.target
|
||||
out_path = Path("/tmp/helios-remote-screenshot.png")
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
if "image" in content_type:
|
||||
out_path.write_bytes(resp.content)
|
||||
if target == "screen":
|
||||
resp = _req("POST", f"/devices/{device}/screenshot")
|
||||
else:
|
||||
# Resolve window label to HWND
|
||||
wid = resolve_window(device, target)
|
||||
resp = _req("POST", f"/devices/{device}/windows/{wid}/screenshot")
|
||||
|
||||
data = resp.json()
|
||||
b64 = (
|
||||
data.get("screenshot")
|
||||
data.get("image_base64")
|
||||
or data.get("screenshot")
|
||||
or data.get("image")
|
||||
or data.get("image_base64")
|
||||
or data.get("data")
|
||||
or data.get("png")
|
||||
)
|
||||
if not b64:
|
||||
sys.exit(
|
||||
f"[helios-remote] ERROR: Screenshot response has no image field.\n"
|
||||
f" Content-Type : {content_type}\n"
|
||||
f" Keys present : {list(data.keys()) if isinstance(data, dict) else type(data).__name__}"
|
||||
)
|
||||
if "," in b64: # strip data-URI prefix (data:image/png;base64,...)
|
||||
sys.exit(f"[helios-remote] ERROR: No image in response. Keys: {list(data.keys())}")
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
out_path.write_bytes(base64.b64decode(b64))
|
||||
|
||||
print(str(out_path))
|
||||
|
||||
|
||||
def cmd_exec(args):
|
||||
"""Run a shell command on the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
"""Run a shell command on the remote device."""
|
||||
device = validate_label(args.device)
|
||||
command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts
|
||||
body = {"command": command}
|
||||
if args.timeout:
|
||||
body["timeout_ms"] = args.timeout * 1000 # seconds → ms
|
||||
resp = _req("POST", f"/sessions/{sid}/exec", json=body, timeout=max(35, (args.timeout or 30) + 5))
|
||||
body["timeout_ms"] = args.timeout * 1000
|
||||
resp = _req("POST", f"/devices/{device}/exec", json=body,
|
||||
timeout=max(35, (args.timeout or 30) + 5))
|
||||
data = resp.json()
|
||||
|
||||
stdout = data.get("stdout") or data.get("output") or ""
|
||||
|
|
@ -178,127 +167,78 @@ def cmd_exec(args):
|
|||
sys.exit(int(exit_code))
|
||||
|
||||
|
||||
def cmd_click(args):
|
||||
"""Send a mouse click to the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
_req("POST", f"/sessions/{sid}/click", json={"x": args.x, "y": args.y})
|
||||
print(f"Clicked ({args.x}, {args.y}) on session {sid!r}.")
|
||||
|
||||
|
||||
def cmd_type(args):
|
||||
"""Send keyboard input to the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
_req("POST", f"/sessions/{sid}/type", json={"text": args.text})
|
||||
print(f"Typed {len(args.text)} chars on session {sid!r}.")
|
||||
|
||||
|
||||
def cmd_windows(args):
|
||||
"""List windows on the remote session (visible only by default)."""
|
||||
sid = resolve_session(args.session_id)
|
||||
resp = _req("GET", f"/sessions/{sid}/windows")
|
||||
"""List windows on the remote device (visible only)."""
|
||||
device = validate_label(args.device)
|
||||
resp = _req("GET", f"/devices/{device}/windows")
|
||||
data = resp.json()
|
||||
windows = data.get("windows", [])
|
||||
# Filter to visible only unless --all is passed
|
||||
all_windows = getattr(args, "all", False)
|
||||
if not all_windows:
|
||||
windows = [w for w in windows if w.get("visible")]
|
||||
if not windows:
|
||||
print("No windows returned.")
|
||||
return
|
||||
print(f"{'ID':<20} Title")
|
||||
print(f"{'Label':<30} Title")
|
||||
print("-" * 70)
|
||||
for w in windows:
|
||||
wid = str(w.get("id", "?"))
|
||||
label = w.get("label", "?")
|
||||
title = w.get("title", "")
|
||||
print(f"{wid:<20} {title}")
|
||||
print(f"{label:<30} {title}")
|
||||
|
||||
|
||||
def cmd_minimize_all(args):
|
||||
"""Minimize all windows on the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
_req("POST", f"/sessions/{sid}/windows/minimize-all")
|
||||
print(f"All windows minimized on session {sid!r}.")
|
||||
"""Minimize all windows on the remote device."""
|
||||
device = validate_label(args.device)
|
||||
_req("POST", f"/devices/{device}/windows/minimize-all")
|
||||
print(f"All windows minimized on {device}.")
|
||||
|
||||
|
||||
def cmd_focus(args):
|
||||
"""Bring a window to the foreground (by ID or title substring)."""
|
||||
sid = resolve_session(args.session_id)
|
||||
wid = resolve_window(sid, args.window_id)
|
||||
_req("POST", f"/sessions/{sid}/windows/{wid}/focus")
|
||||
print(f"Window {wid} focused on session {sid!r}.")
|
||||
"""Bring a window to the foreground (by label)."""
|
||||
device = validate_label(args.device)
|
||||
wid = resolve_window(device, args.window)
|
||||
_req("POST", f"/devices/{device}/windows/{wid}/focus")
|
||||
print(f"Window '{args.window}' focused on {device}.")
|
||||
|
||||
|
||||
def cmd_maximize(args):
|
||||
"""Maximize and focus a window (by ID or title substring)."""
|
||||
sid = resolve_session(args.session_id)
|
||||
wid = resolve_window(sid, args.window_id)
|
||||
_req("POST", f"/sessions/{sid}/windows/{wid}/maximize")
|
||||
print(f"Window {wid} maximized on session {sid!r}.")
|
||||
"""Maximize and focus a window (by label)."""
|
||||
device = validate_label(args.device)
|
||||
wid = resolve_window(device, args.window)
|
||||
_req("POST", f"/devices/{device}/windows/{wid}/maximize")
|
||||
print(f"Window '{args.window}' maximized on {device}.")
|
||||
|
||||
|
||||
def cmd_logs(args):
|
||||
"""Fetch the last N lines of the client log file."""
|
||||
sid = resolve_session(args.session_id)
|
||||
resp = _req("GET", f"/sessions/{sid}/logs", params={"lines": args.lines})
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
sys.exit(f"[helios-remote] {data['error']}")
|
||||
print(f"# {data.get('log_path', '?')} (last {args.lines} lines)")
|
||||
print(data.get("content", ""))
|
||||
def cmd_version(args):
|
||||
"""Show relay, remote.py, and client commit — check if all are in sync."""
|
||||
device = validate_label(args.device)
|
||||
|
||||
# 1. Relay (public, no auth)
|
||||
try:
|
||||
r = requests.get(f"{BASE_URL}/version", timeout=10)
|
||||
relay = r.json() if r.ok else {"commit": f"HTTP {r.status_code}"}
|
||||
except Exception as e:
|
||||
relay = {"commit": str(e)}
|
||||
|
||||
def cmd_screenshot_window(args):
|
||||
"""Capture a specific window by ID or title substring → /tmp/helios-remote-screenshot.png"""
|
||||
sid = resolve_session(args.session_id)
|
||||
wid = resolve_window(sid, args.window_id)
|
||||
resp = _req("POST", f"/sessions/{sid}/windows/{wid}/screenshot")
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
sys.exit(f"[helios-remote] {data['error']}")
|
||||
import base64, os
|
||||
out_path = args.output or "/tmp/helios-remote-screenshot.png"
|
||||
img_bytes = base64.b64decode(data["image_base64"])
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
print(out_path)
|
||||
return out_path
|
||||
|
||||
|
||||
def _script_commit() -> str:
|
||||
"""Return the git commit hash of remote.py itself."""
|
||||
import subprocess, os
|
||||
# 2. remote.py commit
|
||||
import subprocess
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "log", "-1", "--format=%h", "--", __file__],
|
||||
capture_output=True, text=True,
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
return r.stdout.strip() or "unknown"
|
||||
script_commit = r.stdout.strip() or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
script_commit = "unknown"
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
"""Show relay, remote.py, and client commit in one call."""
|
||||
import requests as _requests
|
||||
|
||||
# 1. Relay (public endpoint, no auth)
|
||||
# 3. Client
|
||||
try:
|
||||
r = _requests.get(f"{BASE_URL}/version", timeout=10)
|
||||
relay = r.json() if r.ok else {"commit": f"HTTP {r.status_code}"}
|
||||
except Exception as e:
|
||||
relay = {"commit": str(e)}
|
||||
|
||||
# 2. Client (via session)
|
||||
sid = resolve_session(args.session_id)
|
||||
try:
|
||||
r2 = _req("GET", f"/sessions/{sid}/version")
|
||||
r2 = _req("GET", f"/devices/{device}/version")
|
||||
client = r2.json()
|
||||
except Exception as e:
|
||||
client = {"commit": str(e)}
|
||||
except SystemExit:
|
||||
client = {"commit": "unreachable"}
|
||||
|
||||
relay_commit = relay.get("commit", "?")
|
||||
script_commit = _script_commit()
|
||||
client_commit = client.get("commit", "?")
|
||||
all_same = relay_commit == script_commit == client_commit
|
||||
|
||||
|
|
@ -308,53 +248,41 @@ def cmd_status(args):
|
|||
print(f" {'✅ all in sync' if all_same else '⚠️ OUT OF SYNC'}")
|
||||
|
||||
|
||||
def cmd_server_version(_args):
|
||||
"""Get server version (no auth required)."""
|
||||
import requests as _requests
|
||||
try:
|
||||
resp = _requests.get(f"{BASE_URL}/version", timeout=10)
|
||||
except Exception as exc:
|
||||
sys.exit(f"[helios-remote] CONNECTION ERROR: {exc}")
|
||||
if not resp.ok:
|
||||
sys.exit(f"[helios-remote] HTTP {resp.status_code}: {resp.text[:500]}")
|
||||
def cmd_logs(args):
|
||||
"""Fetch the last N lines of the client log file."""
|
||||
device = validate_label(args.device)
|
||||
resp = _req("GET", f"/devices/{device}/logs", params={"lines": args.lines})
|
||||
data = resp.json()
|
||||
print(f"Server version : {data.get('version', '?')}")
|
||||
print(f"Commit : {data.get('commit', '?')}")
|
||||
|
||||
|
||||
def cmd_version(args):
|
||||
"""Get the client version for a session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
resp = _req("GET", f"/sessions/{sid}/version")
|
||||
data = resp.json()
|
||||
print(f"Client version : {data.get('version', '?')}")
|
||||
print(f"Commit : {data.get('commit', '?')}")
|
||||
if "error" in data:
|
||||
sys.exit(f"[helios-remote] {data['error']}")
|
||||
print(f"# {data.get('log_path', '?')} (last {args.lines} lines)")
|
||||
print(data.get("content", ""))
|
||||
|
||||
|
||||
def cmd_upload(args):
|
||||
"""Upload a local file to the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
"""Upload a local file to the remote device."""
|
||||
device = validate_label(args.device)
|
||||
local_path = Path(args.local_path)
|
||||
if not local_path.exists():
|
||||
sys.exit(f"[helios-remote] ERROR: Local file not found: {local_path}")
|
||||
content_base64 = base64.b64encode(local_path.read_bytes()).decode()
|
||||
_req("POST", f"/sessions/{sid}/upload", json={
|
||||
_req("POST", f"/devices/{device}/upload", json={
|
||||
"path": args.remote_path,
|
||||
"content_base64": content_base64,
|
||||
})
|
||||
print(f"Uploaded {local_path} → {args.remote_path} on session {sid!r}.")
|
||||
print(f"Uploaded {local_path} → {args.remote_path} on {device}.")
|
||||
|
||||
|
||||
def cmd_download(args):
|
||||
"""Download a file from the remote session to a local path."""
|
||||
sid = resolve_session(args.session_id)
|
||||
"""Download a file from the remote device to a local path."""
|
||||
device = validate_label(args.device)
|
||||
from urllib.parse import quote
|
||||
encoded_path = quote(args.remote_path, safe="")
|
||||
resp = _req("GET", f"/sessions/{sid}/download?path={encoded_path}")
|
||||
resp = _req("GET", f"/devices/{device}/download?path={encoded_path}")
|
||||
data = resp.json()
|
||||
b64 = data.get("content_base64", "")
|
||||
if not b64:
|
||||
sys.exit(f"[helios-remote] ERROR: No content in download response. Keys: {list(data.keys())}")
|
||||
sys.exit(f"[helios-remote] ERROR: No content in download response.")
|
||||
local_path = Path(args.local_path)
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(base64.b64decode(b64))
|
||||
|
|
@ -362,84 +290,41 @@ def cmd_download(args):
|
|||
print(f"Downloaded {args.remote_path} → {local_path} ({size} bytes).")
|
||||
|
||||
|
||||
def cmd_find_window(args):
|
||||
"""Find visible windows by title substring on the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
resp = _req("GET", f"/sessions/{sid}/windows")
|
||||
data = resp.json()
|
||||
windows = data.get("windows", [])
|
||||
query = args.title.lower()
|
||||
# Only visible windows
|
||||
matches = [w for w in windows if w.get("visible") and query in w.get("title", "").lower()]
|
||||
if not matches:
|
||||
print(f"No visible windows matching '{args.title}'.")
|
||||
return
|
||||
print(f"{'ID':<20} Title")
|
||||
print("-" * 70)
|
||||
for w in matches:
|
||||
wid = str(w.get("id", "?"))
|
||||
title = w.get("title", "")
|
||||
print(f"{wid:<20} {title}")
|
||||
|
||||
|
||||
def cmd_prompt(args):
|
||||
"""Show a MessageBox on the remote PC asking the user to do something.
|
||||
Blocks until the user clicks OK — use this when the AI needs the user
|
||||
to perform a manual action (e.g. click a button, confirm a dialog)."""
|
||||
sid = resolve_session(args.session_id)
|
||||
"""Show a MessageBox on the remote PC asking the user to do something."""
|
||||
device = validate_label(args.device)
|
||||
body = {"message": args.message}
|
||||
if args.title:
|
||||
body["title"] = args.title
|
||||
resp = _req("POST", f"/sessions/{sid}/prompt", json=body)
|
||||
answer = resp.get("answer", "")
|
||||
resp = _req("POST", f"/devices/{device}/prompt", json=body)
|
||||
data = resp.json()
|
||||
answer = data.get("answer", "")
|
||||
if answer:
|
||||
print(answer)
|
||||
else:
|
||||
print(f"Prompt confirmed (no answer returned).")
|
||||
|
||||
|
||||
def cmd_wait_for_window(args):
|
||||
"""Poll until a visible window with the given title appears (or timeout)."""
|
||||
import time
|
||||
sid = resolve_session(args.session_id)
|
||||
query = args.title.lower()
|
||||
deadline = time.time() + args.timeout
|
||||
interval = 1.0
|
||||
print(f"Waiting for window matching '{args.title}' (timeout: {args.timeout}s)...")
|
||||
while time.time() < deadline:
|
||||
resp = _req("GET", f"/sessions/{sid}/windows")
|
||||
windows = resp.json().get("windows", [])
|
||||
matches = [w for w in windows if w.get("visible") and query in w.get("title", "").lower()]
|
||||
if matches:
|
||||
print(f"{'ID':<20} Title")
|
||||
print("-" * 70)
|
||||
for w in matches:
|
||||
print(f"{str(w.get('id','?')):<20} {w.get('title','')}")
|
||||
return
|
||||
time.sleep(interval)
|
||||
sys.exit(f"[helios-remote] Timeout: no window matching '{args.title}' appeared within {args.timeout}s")
|
||||
print("Prompt confirmed.")
|
||||
|
||||
|
||||
def cmd_run(args):
|
||||
"""Launch a program on the remote session (fire-and-forget)."""
|
||||
sid = resolve_session(args.session_id)
|
||||
_req("POST", f"/sessions/{sid}/run", json={"program": args.program, "args": args.args})
|
||||
print(f"Started {args.program!r} on session {sid!r}.")
|
||||
"""Launch a program on the remote device (fire-and-forget)."""
|
||||
device = validate_label(args.device)
|
||||
_req("POST", f"/devices/{device}/run", json={"program": args.program, "args": args.args})
|
||||
print(f"Started {args.program!r} on {device}.")
|
||||
|
||||
|
||||
def cmd_clipboard_get(args):
|
||||
"""Get the clipboard contents from the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
resp = _req("GET", f"/sessions/{sid}/clipboard")
|
||||
"""Get the clipboard contents from the remote device."""
|
||||
device = validate_label(args.device)
|
||||
resp = _req("GET", f"/devices/{device}/clipboard")
|
||||
data = resp.json()
|
||||
print(data.get("text", ""))
|
||||
|
||||
|
||||
def cmd_clipboard_set(args):
|
||||
"""Set the clipboard contents on the remote session."""
|
||||
sid = resolve_session(args.session_id)
|
||||
_req("POST", f"/sessions/{sid}/clipboard", json={"text": args.text})
|
||||
print(f"Clipboard set ({len(args.text)} chars) on session {sid!r}.")
|
||||
"""Set the clipboard contents on the remote device."""
|
||||
device = validate_label(args.device)
|
||||
_req("POST", f"/devices/{device}/clipboard", json={"text": args.text})
|
||||
print(f"Clipboard set ({len(args.text)} chars) on {device}.")
|
||||
|
||||
|
||||
# ── CLI wiring ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -447,103 +332,69 @@ def cmd_clipboard_set(args):
|
|||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="remote.py",
|
||||
description="Control PCs connected to the Helios Remote Relay Server.",
|
||||
description="Control devices connected to the Helios Remote Relay Server.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="subcmd", required=True)
|
||||
|
||||
sub.add_parser("sessions", help="List all connected sessions")
|
||||
sub.add_parser("devices", help="List all connected devices")
|
||||
|
||||
lp = sub.add_parser("label", help="Assign a name to a session")
|
||||
lp.add_argument("session_id")
|
||||
lp.add_argument("name")
|
||||
sp = sub.add_parser("screenshot", help="Capture screenshot (screen or window label)")
|
||||
sp.add_argument("device", help="Device label")
|
||||
sp.add_argument("target", help="'screen' for full screen, or a window label")
|
||||
|
||||
sp = sub.add_parser("screenshot", help="Capture screenshot → /tmp/helios-remote-screenshot.png")
|
||||
sp.add_argument("session_id")
|
||||
|
||||
swp = sub.add_parser("screenshot-window", help="Capture a specific window (by ID or title)")
|
||||
swp.add_argument("session_id")
|
||||
swp.add_argument("window_id", help="Window ID (number) or title substring")
|
||||
swp.add_argument("--output", default=None, help="Output path (default: /tmp/helios-remote-screenshot.png)")
|
||||
swp.set_defaults(func=cmd_screenshot_window)
|
||||
|
||||
ep = sub.add_parser("exec", help="Run a shell command on the remote session")
|
||||
ep.add_argument("session_id")
|
||||
ep = sub.add_parser("exec", help="Run a shell command on the remote device")
|
||||
ep.add_argument("device", help="Device label")
|
||||
ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command",
|
||||
help="Command (and arguments) to execute")
|
||||
ep.add_argument("--timeout", type=int, default=None, metavar="SECONDS",
|
||||
help="Timeout in seconds (default: 30). Use higher for long downloads etc.")
|
||||
help="Timeout in seconds (default: 30)")
|
||||
|
||||
cp = sub.add_parser("click", help="Send a mouse click")
|
||||
cp.add_argument("session_id")
|
||||
cp.add_argument("x", type=int)
|
||||
cp.add_argument("y", type=int)
|
||||
|
||||
tp = sub.add_parser("type", help="Send keyboard input")
|
||||
tp.add_argument("session_id")
|
||||
tp.add_argument("text")
|
||||
|
||||
wp = sub.add_parser("windows", help="List all windows on the remote session")
|
||||
wp.add_argument("session_id")
|
||||
wp = sub.add_parser("windows", help="List all visible windows on the remote device")
|
||||
wp.add_argument("device", help="Device label")
|
||||
|
||||
mp = sub.add_parser("minimize-all", help="Minimize all windows")
|
||||
mp.add_argument("session_id")
|
||||
mp.add_argument("device", help="Device label")
|
||||
|
||||
fp = sub.add_parser("focus", help="Bring a window to the foreground")
|
||||
fp.add_argument("session_id")
|
||||
fp.add_argument("window_id")
|
||||
fp.add_argument("device", help="Device label")
|
||||
fp.add_argument("window", help="Window label")
|
||||
|
||||
xp = sub.add_parser("maximize", help="Maximize and focus a window")
|
||||
xp.add_argument("session_id")
|
||||
xp.add_argument("window_id")
|
||||
xp.add_argument("device", help="Device label")
|
||||
xp.add_argument("window", help="Window label")
|
||||
|
||||
stp = sub.add_parser("status", help="Show relay + client commit and sync status")
|
||||
stp.add_argument("session_id")
|
||||
vp = sub.add_parser("version", help="Compare relay, remote.py, and client commits")
|
||||
vp.add_argument("device", help="Device label")
|
||||
|
||||
lgp = sub.add_parser("logs", help="Fetch last N lines of client log file")
|
||||
lgp.add_argument("session_id")
|
||||
lgp.add_argument("--lines", type=int, default=100, metavar="N", help="Number of lines (default: 100)")
|
||||
lgp.add_argument("device", help="Device label")
|
||||
lgp.add_argument("--lines", type=int, default=100, metavar="N")
|
||||
|
||||
sub.add_parser("server-version", help="Get server version (no auth required)")
|
||||
|
||||
vp = sub.add_parser("version", help="Get client version for a session")
|
||||
vp.add_argument("session_id")
|
||||
|
||||
up = sub.add_parser("upload", help="Upload a local file to the remote session")
|
||||
up.add_argument("session_id")
|
||||
up = sub.add_parser("upload", help="Upload a local file to the remote device")
|
||||
up.add_argument("device", help="Device label")
|
||||
up.add_argument("local_path")
|
||||
up.add_argument("remote_path")
|
||||
|
||||
dp = sub.add_parser("download", help="Download a file from the remote session")
|
||||
dp.add_argument("session_id")
|
||||
dp = sub.add_parser("download", help="Download a file from the remote device")
|
||||
dp.add_argument("device", help="Device label")
|
||||
dp.add_argument("remote_path")
|
||||
dp.add_argument("local_path")
|
||||
|
||||
fwp = sub.add_parser("find-window", help="Find windows by title substring")
|
||||
fwp.add_argument("session_id")
|
||||
fwp.add_argument("title", help="Substring to search for (case-insensitive)")
|
||||
pp = sub.add_parser("prompt", help="Show a MessageBox asking the user to do something")
|
||||
pp.add_argument("device", help="Device label")
|
||||
pp.add_argument("message")
|
||||
pp.add_argument("--title", default=None)
|
||||
|
||||
wfwp = sub.add_parser("wait-for-window", help="Poll until a window with given title appears")
|
||||
wfwp.add_argument("session_id")
|
||||
wfwp.add_argument("title", help="Substring to wait for (case-insensitive)")
|
||||
wfwp.add_argument("--timeout", type=int, default=30, metavar="SECONDS", help="Max wait time (default: 30s)")
|
||||
wfwp.set_defaults(func=cmd_wait_for_window)
|
||||
rp = sub.add_parser("run", help="Launch a program (fire-and-forget)")
|
||||
rp.add_argument("device", help="Device label")
|
||||
rp.add_argument("program")
|
||||
rp.add_argument("args", nargs=argparse.REMAINDER)
|
||||
|
||||
pp = sub.add_parser("prompt", help="Show a MessageBox asking the user to do something manually")
|
||||
pp.add_argument("session_id")
|
||||
pp.add_argument("message", help="What to ask the user (e.g. 'Please click Save, then OK')")
|
||||
pp.add_argument("--title", default=None, help="Dialog title (default: Helios Remote)")
|
||||
pp.set_defaults(func=cmd_prompt)
|
||||
cgp = sub.add_parser("clipboard-get", help="Get clipboard contents")
|
||||
cgp.add_argument("device", help="Device label")
|
||||
|
||||
rp = sub.add_parser("run", help="Launch a program on the remote session (fire-and-forget)")
|
||||
rp.add_argument("session_id")
|
||||
rp.add_argument("program", help="Program to launch (e.g. notepad.exe)")
|
||||
rp.add_argument("args", nargs=argparse.REMAINDER, help="Optional arguments")
|
||||
|
||||
cgp = sub.add_parser("clipboard-get", help="Get clipboard contents from the remote session")
|
||||
cgp.add_argument("session_id")
|
||||
|
||||
csp = sub.add_parser("clipboard-set", help="Set clipboard contents on the remote session")
|
||||
csp.add_argument("session_id")
|
||||
csp = sub.add_parser("clipboard-set", help="Set clipboard contents")
|
||||
csp.add_argument("device", help="Device label")
|
||||
csp.add_argument("text")
|
||||
|
||||
return p
|
||||
|
|
@ -554,27 +405,19 @@ def main():
|
|||
args = parser.parse_args()
|
||||
|
||||
{
|
||||
"sessions": cmd_sessions,
|
||||
"label": cmd_label,
|
||||
"devices": cmd_devices,
|
||||
"screenshot": cmd_screenshot,
|
||||
"exec": cmd_exec,
|
||||
"click": cmd_click,
|
||||
"type": cmd_type,
|
||||
"windows": cmd_windows,
|
||||
"minimize-all": cmd_minimize_all,
|
||||
"focus": cmd_focus,
|
||||
"maximize": cmd_maximize,
|
||||
"server-version": cmd_server_version,
|
||||
"version": cmd_version,
|
||||
"logs": cmd_logs,
|
||||
"upload": cmd_upload,
|
||||
"download": cmd_download,
|
||||
"status": cmd_status,
|
||||
"logs": cmd_logs,
|
||||
"screenshot-window": cmd_screenshot_window,
|
||||
"find-window": cmd_find_window,
|
||||
"wait-for-window": cmd_wait_for_window,
|
||||
"run": cmd_run,
|
||||
"prompt": cmd_prompt,
|
||||
"run": cmd_run,
|
||||
"clipboard-get": cmd_clipboard_get,
|
||||
"clipboard-set": cmd_clipboard_set,
|
||||
}[args.subcmd](args)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Helios Remote - Install Script
|
||||
# Downloads and starts the helios-remote Windows client
|
||||
# Downloads and starts the helios-remote Windows client.
|
||||
#
|
||||
# IMPORTANT: Windows Defender may block the executable.
|
||||
# If the download or launch fails, disable real-time protection first (run as Admin):
|
||||
|
|
@ -14,7 +14,6 @@ $dest = "$env:USERPROFILE\Desktop\Helios Remote.exe"
|
|||
|
||||
Write-Host "Downloading helios-remote client..."
|
||||
|
||||
# Try curl.exe first (built-in on Windows 10+), fall back to WebClient
|
||||
if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
|
||||
curl.exe -L -o $dest $url
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue