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:
Helios 2026-03-06 01:55:28 +01:00
parent 5fd01a423d
commit 0b4a6de8ae
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
14 changed files with 736 additions and 1180 deletions

160
README.md
View file

@ -33,7 +33,8 @@ helios-remote/
├── crates/ ├── crates/
│ ├── common/ # Shared protocol types, WebSocket message definitions │ ├── common/ # Shared protocol types, WebSocket message definitions
│ ├── server/ # Relay server (REST API + WebSocket hub) │ ├── 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 ├── Cargo.toml # Workspace root
└── README.md └── README.md
``` ```
@ -46,44 +47,56 @@ AI Agent
helios-server ──WebSocket── helios-client (Windows) helios-server ──WebSocket── helios-client (Windows)
│ │ │ │
POST /sessions/:id/screenshot │ Captures screen → base64 PNG POST /devices/:label/screenshot │ Captures screen → base64 PNG
POST /sessions/:id/exec │ Runs command in persistent shell POST /devices/:label/exec │ Runs command in persistent shell
POST /sessions/:id/click │ Simulates mouse click
POST /sessions/:id/type │ Types text
``` ```
1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` message. 1. The **Windows client** connects to the relay server via WebSocket and sends a `Hello` with its device label.
2. The **AI agent** calls the REST API to issue commands. 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 session and streams back responses. 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 ## Server
### REST API ### 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 | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/sessions` | List all connected clients | | `GET` | `/devices` | List all connected devices |
| `POST` | `/sessions/:id/screenshot` | Request a screenshot (returns base64 PNG) | | `POST` | `/devices/:label/screenshot` | Full screen screenshot (base64 PNG) |
| `POST` | `/sessions/:id/exec` | Execute a shell command | | `POST` | `/devices/:label/exec` | Execute a shell command |
| `POST` | `/sessions/:id/click` | Simulate a mouse click | | `GET` | `/devices/:label/windows` | List visible windows (with labels) |
| `POST` | `/sessions/:id/type` | Type text | | `POST` | `/devices/:label/windows/minimize-all` | Minimize all windows |
| `POST` | `/sessions/:id/label` | Rename a session | | `POST` | `/devices/:label/windows/:window_id/screenshot` | Screenshot a specific window |
| `GET` | `/sessions/:id/windows` | List all windows | | `POST` | `/devices/:label/windows/:window_id/focus` | Focus a window |
| `POST` | `/sessions/:id/windows/minimize-all` | Minimize all windows | | `POST` | `/devices/:label/windows/:window_id/maximize` | Maximize and focus a window |
| `POST` | `/sessions/:id/windows/:window_id/focus` | Focus a window | | `POST` | `/devices/:label/prompt` | Show a MessageBox (blocks until OK) |
| `POST` | `/sessions/:id/windows/:window_id/maximize` | Maximize and focus a window | | `POST` | `/devices/:label/run` | Launch a program (fire-and-forget) |
| `POST` | `/sessions/:id/run` | Launch a program (fire-and-forget) | | `GET` | `/devices/:label/clipboard` | Get clipboard contents |
| `GET` | `/sessions/:id/clipboard` | Get clipboard contents | | `POST` | `/devices/:label/clipboard` | Set clipboard contents |
| `POST` | `/sessions/:id/clipboard` | Set clipboard contents | | `GET` | `/devices/:label/version` | Get client version/commit |
| `GET` | `/sessions/:id/version` | Get client version | | `POST` | `/devices/:label/upload` | Upload a file to the client |
| `POST` | `/sessions/:id/upload` | Upload a file to the client | | `GET` | `/devices/:label/download?path=...` | Download a file from the client |
| `GET` | `/sessions/:id/download?path=...` | Download a file from the client | | `GET` | `/devices/:label/logs` | Fetch client log tail |
| `GET` | `/version` | Server version/commit (no auth) |
### WebSocket ### 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 ### 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 HELIOS_API_KEY=your-secret-key HELIOS_BIND=0.0.0.0:3000 cargo run -p helios-server
``` ```
Environment variables:
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `HELIOS_API_KEY` | `dev-secret` | API key for REST endpoints | | `HELIOS_API_KEY` | `dev-secret` | API key for REST endpoints |
@ -102,74 +113,67 @@ Environment variables:
### Example API Usage ### Example API Usage
```bash ```bash
# List sessions # List devices
curl -H "X-Api-Key: your-secret-key" http://localhost:3000/sessions 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" \ 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 # Run a command
curl -s -X POST -H "X-Api-Key: your-secret-key" \ curl -s -X POST -H "X-Api-Key: your-secret-key" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command": "whoami"}' \ -d '{"command": "whoami"}' \
http://localhost:3000/sessions/<session-id>/exec http://localhost:3000/devices/moritz_pc/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
``` ```
## remote.py CLI ## remote.py CLI
The `skills/helios-remote/remote.py` script provides a simple CLI wrapper around the REST API. The `remote.py` script provides a 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
```
### Commands ### Commands
```bash ```bash
python remote.py sessions # list sessions python remote.py devices # list connected devices
python remote.py screenshot <session> # capture screenshot → /tmp/helios-remote-screenshot.png python remote.py screenshot <device> screen # full-screen screenshot → /tmp/helios-remote-screenshot.png
python remote.py exec <session> <command...> # run shell command (PowerShell, no wrapper needed) python remote.py screenshot <device> google_chrome # screenshot a specific window by label
python remote.py exec <session> --timeout 600 <command...> # with custom timeout (seconds, default: 30) python remote.py exec <device> <command...> # run shell command (PowerShell)
python remote.py prompt <session> "Please click Save, then OK" # ask user to do something manually python remote.py exec <device> --timeout 600 <command...> # with custom timeout (seconds)
python remote.py click <session> <x> <y> # mouse click python remote.py windows <device> # list visible windows (with labels)
python remote.py type <session> <text> # keyboard input python remote.py focus <device> <window_label> # focus a window
python remote.py windows <session> # list windows python remote.py maximize <device> <window_label> # maximize and focus a window
python remote.py find-window <session> <title> # filter windows by title substring python remote.py minimize-all <device> # minimize all windows
python remote.py minimize-all <session> # minimize all windows python remote.py prompt <device> "Please click Save" # ask user to do something manually
python remote.py focus <session> <window_id> # focus window python remote.py prompt <device> "message" --title "Title" # with custom dialog title
python remote.py maximize <session> <window_id> # maximize and focus window python remote.py run <device> <program> [args...] # launch program (fire-and-forget)
python remote.py run <session> <program> [args...] # launch program (fire-and-forget) python remote.py clipboard-get <device> # get clipboard text
python remote.py clipboard-get <session> # get clipboard text python remote.py clipboard-set <device> <text> # set clipboard text
python remote.py clipboard-set <session> <text> # set clipboard text python remote.py upload <device> <local> <remote> # upload file
python remote.py upload <session> <local> <remote> # upload file python remote.py download <device> <remote> <local> # download file
python remote.py download <session> <remote> <local> # download file python remote.py version <device> # compare relay/remote.py/client commits
python remote.py screenshot-window <session> <window_id_or_title> # screenshot a specific window python remote.py logs <device> # fetch last 100 lines of client log
python remote.py screenshot-window <session> <title> --output /tmp/out.png # custom output path python remote.py logs <device> --lines 200 # custom line count
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)
``` ```
## 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 ## Development

130
SKILL.md
View file

@ -1,110 +1,92 @@
# Skill: helios-remote # 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: When Moritz asks to do something on a connected PC:
- "Mach auf meinem PC..." - "Do X on my PC..."
- "Schau mal was auf dem Rechner läuft..." - "Check what's running on the computer..."
- "Nimm einen Screenshot von..." - "Take a screenshot of..."
- "Klick auf X, tippe Y..." - General: remote access to an online PC
- Allgemein: Remote-Zugriff auf einen PC der gerade online ist
## Setup ## Setup
- **Script:** `skills/helios-remote/remote.py` - **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` - `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! - **Before destructive actions** (wallpaper, registry, system settings, deleting files) always read the current state first!
- Wallpaper auslesen: `(Get-ItemProperty 'HKCU:\Control Panel\Desktop').WallPaper` - Wallpaper: `(Get-ItemProperty 'HKCU:\Control Panel\Desktop').WallPaper`
- **Label-Routing:** `session_id` kann UUID oder Label-Name sein → einfach `"Moritz PC"` verwenden - **Device labels are lowercase**, no whitespace, only `a-z 0-9 - _` (e.g. `moritz_pc`)
## Befehle ## Commands
```bash ```bash
SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote SKILL_DIR=/home/moritz/.openclaw/workspace/skills/helios-remote
# Sessions # Devices
python $SKILL_DIR/remote.py sessions python $SKILL_DIR/remote.py devices
python $SKILL_DIR/remote.py server-version
python $SKILL_DIR/remote.py version "Moritz PC"
# Screenshot → /tmp/helios-remote-screenshot.png # Screenshot → /tmp/helios-remote-screenshot.png
# IMMER screenshot-window bevorzugen (spart Bandbreite)! # ALWAYS prefer window screenshots (saves bandwidth)!
python $SKILL_DIR/remote.py screenshot-window "Moritz PC" "chrome" # aktives Fenster by title python $SKILL_DIR/remote.py screenshot moritz_pc google_chrome # window by label
python $SKILL_DIR/remote.py screenshot "Moritz PC" # nur wenn kein Fenster bekannt python $SKILL_DIR/remote.py screenshot moritz_pc screen # full screen only when no window known
# Shell-Befehl (PowerShell, kein wrapper nötig) # Shell command (PowerShell, no wrapper needed)
python $SKILL_DIR/remote.py exec "Moritz PC" "Get-Process" python $SKILL_DIR/remote.py exec moritz_pc "Get-Process"
python $SKILL_DIR/remote.py exec "Moritz PC" "hostname" python $SKILL_DIR/remote.py exec moritz_pc "hostname"
# Mit längerer Timeout für Downloads etc. (default: 30s) # 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" python $SKILL_DIR/remote.py exec moritz_pc --timeout 600 "Invoke-WebRequest -Uri https://... -OutFile C:\file.zip"
# Mausklick / Text tippen # Windows (visible only, shown with human-readable labels)
python $SKILL_DIR/remote.py click "Moritz PC" 960 540 python $SKILL_DIR/remote.py windows moritz_pc
python $SKILL_DIR/remote.py type "Moritz PC" "Hello World" 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) # Launch program (fire-and-forget)
python $SKILL_DIR/remote.py windows "Moritz PC" python $SKILL_DIR/remote.py run moritz_pc notepad.exe
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"
# Programm starten (fire-and-forget) # Ask user to do something (shows MessageBox, blocks until OK)
python $SKILL_DIR/remote.py run "Moritz PC" notepad.exe 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"
# 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"
# Clipboard # Clipboard
python $SKILL_DIR/remote.py clipboard-get "Moritz PC" 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-set moritz_pc "Text for clipboard"
# Dateien hoch-/runterladen # File transfer
python $SKILL_DIR/remote.py upload "Moritz PC" /tmp/local.txt "C:\Users\Moritz\Desktop\remote.txt" 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 python $SKILL_DIR/remote.py download moritz_pc "C:\Users\Moritz\file.txt" /tmp/downloaded.txt
# Label setzen # Version: compare relay + remote.py + client commits (are they in sync?)
python $SKILL_DIR/remote.py label "Moritz PC" "Neues Label" 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 1. `screenshot <device> screen` → look at the screen
2. `find-window` → Fenster-ID holen 2. `windows <device>` → find the window label
3. `focus` → Fenster in den Vordergrund 3. `focus <device> <window_label>` → bring it to front
4. `exec` / `click` / `type` → Aktion ausführen 4. `exec` → perform the action
5. `screenshot` → Ergebnis prüfen 5. `screenshot <device> <window_label>` → verify result
# Warten bis ein Fenster erscheint (z.B. nach Programmstart) ## ⚠️ Prompt Rule (important!)
python $SKILL_DIR/remote.py wait-for-window "Moritz PC" "notepad" --timeout 10
# Status: Relay + remote.py + Client-Commit vergleichen (sind alle in sync?) **Never interact with UI blindly.** When you need the user to click something:
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
```bash ```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.

View file

@ -24,6 +24,7 @@ base64 = "0.22"
png = "0.17" png = "0.17"
futures-util = "0.3" futures-util = "0.3"
colored = "2" colored = "2"
scopeguard = "1"
terminal_size = "0.3" terminal_size = "0.3"
unicode-width = "0.1" unicode-width = "0.1"

View file

@ -1,36 +1,40 @@
# helios-client (Phase 2 — not yet implemented) # helios-client
This crate will contain the Windows remote-control client for `helios-remote`. Windows client for helios-remote. Connects to the relay server via WebSocket and executes commands.
## Planned Features ## Features
- Connects to the relay server via WebSocket (`wss://`) - Full-screen and per-window screenshots
- Sends a `Hello` message on connect with an optional display label - Shell command execution (persistent PowerShell session)
- Handles incoming `ServerMessage` commands: - Window management (list, focus, maximize, minimize)
- `ScreenshotRequest` → captures the primary display (Windows GDI or `windows-capture`) and responds with base64 PNG - File upload/download
- `ExecRequest` → runs a shell command in a persistent `cmd.exe` / PowerShell session and returns stdout/stderr/exit-code - Clipboard get/set
- `ClickRequest` → simulates a mouse click via `SendInput` Win32 API - Program launch (fire-and-forget)
- `TypeRequest` → types text via `SendInput` (virtual key events) - User prompts (MessageBox)
- Persistent shell session so `cd C:\Users` persists across `exec` calls - Single instance enforcement (PID lock file)
- Auto-reconnect with exponential backoff
- Configurable via environment variables or a `client.toml` config file
## Planned Tech Stack ## Configuration
| Crate | Purpose | On first run, the client prompts for:
|---|---| - **Relay URL** (default: `wss://remote.agent-helios.me/ws`)
| `tokio` | Async runtime | - **API Key**
| `tokio-tungstenite` | WebSocket client | - **Device label** — must be lowercase, no whitespace, only `a-z 0-9 - _`
| `serde_json` | Protocol serialization |
| `windows` / `winapi` | Screen capture, mouse/keyboard input |
| `base64` | PNG encoding for screenshots |
## Build Target Config is saved to `%APPDATA%/helios-remote/config.toml`.
## Device Labels
The device label is the sole identifier for this machine. It must follow these rules:
- Lowercase only
- No whitespace
- Only characters: `a-z`, `0-9`, `-`, `_`
Examples: `moritz_pc`, `work-desktop`, `gaming-rig`
If an existing config has an invalid label, it will be automatically migrated on next startup.
## Build
```bash
cargo build -p helios-client --release
``` ```
cargo build --target x86_64-pc-windows-gnu
```
## App Icon
The file `assets/logo.ico` in the repository root is the application icon intended for the Windows `.exe`. It can be embedded at compile time using a build script (e.g. via the `winres` crate).

View file

@ -1,154 +0,0 @@
/// Mouse click and keyboard input via Windows SendInput (or stub on non-Windows).
use helios_common::MouseButton;
#[cfg(windows)]
pub fn click(x: i32, y: i32, button: &MouseButton) -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN,
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEINPUT,
};
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
unsafe {
let screen_w = GetSystemMetrics(SM_CXSCREEN) as i32;
let screen_h = GetSystemMetrics(SM_CYSCREEN) as i32;
if screen_w == 0 || screen_h == 0 {
return Err(format!(
"Could not get screen dimensions: {screen_w}x{screen_h}"
));
}
// Convert pixel coords to absolute 0-65535 range
let abs_x = ((x * 65535) / screen_w) as i32;
let abs_y = ((y * 65535) / screen_h) as i32;
let (down_flag, up_flag) = match button {
MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
};
// Move to position
let move_input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
mi: MOUSEINPUT {
dx: abs_x,
dy: abs_y,
mouseData: 0,
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE,
time: 0,
dwExtraInfo: 0,
},
},
};
let down_input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
mi: MOUSEINPUT {
dx: abs_x,
dy: abs_y,
mouseData: 0,
dwFlags: down_flag | MOUSEEVENTF_ABSOLUTE,
time: 0,
dwExtraInfo: 0,
},
},
};
let up_input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
mi: MOUSEINPUT {
dx: abs_x,
dy: abs_y,
mouseData: 0,
dwFlags: up_flag | MOUSEEVENTF_ABSOLUTE,
time: 0,
dwExtraInfo: 0,
},
},
};
let inputs = [move_input, down_input, up_input];
let result = SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
if result != inputs.len() as u32 {
return Err(format!(
"SendInput for click at ({x},{y}) sent {result}/{} events — some may have been blocked by UIPI",
inputs.len()
));
}
Ok(())
}
}
#[cfg(windows)]
pub fn type_text(text: &str) -> Result<(), String> {
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_UNICODE,
};
if text.is_empty() {
return Ok(());
}
unsafe {
let mut inputs: Vec<INPUT> = Vec::with_capacity(text.len() * 2);
for ch in text.encode_utf16() {
// Key down
inputs.push(INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: ch,
dwFlags: KEYEVENTF_UNICODE,
time: 0,
dwExtraInfo: 0,
},
},
});
// Key up
inputs.push(INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
ki: KEYBDINPUT {
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
wScan: ch,
dwFlags: KEYEVENTF_UNICODE
| windows::Win32::UI::Input::KeyboardAndMouse::KEYEVENTF_KEYUP,
time: 0,
dwExtraInfo: 0,
},
},
});
}
let result = SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
if result != inputs.len() as u32 {
return Err(format!(
"SendInput for type_text sent {result}/{} events — some may have been blocked (UIPI or secure desktop)",
inputs.len()
));
}
Ok(())
}
}
#[cfg(not(windows))]
pub fn click(_x: i32, _y: i32, _button: &MouseButton) -> Result<(), String> {
Err("click() is only supported on Windows".to_string())
}
#[cfg(not(windows))]
pub fn type_text(_text: &str) -> Result<(), String> {
Err("type_text() is only supported on Windows".to_string())
}

View file

@ -11,27 +11,23 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con
use base64::Engine; use base64::Engine;
use helios_common::{ClientMessage, ServerMessage}; use helios_common::{ClientMessage, ServerMessage};
use uuid::Uuid; use helios_common::protocol::{is_valid_label, sanitize_label};
mod display; mod display;
mod logger; mod logger;
mod shell; mod shell;
mod screenshot; mod screenshot;
mod input;
mod windows_mgmt; mod windows_mgmt;
// Re-export trunc for use in this file
use display::trunc; use display::trunc;
fn banner() { fn banner() {
println!(); 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()); println!(" {} {}", "".yellow().bold(), "HELIOS REMOTE".bold());
display::info_line("🔗", "commit:", &env!("GIT_COMMIT").dimmed().to_string()); 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)] #[cfg(windows)]
{ {
let admin = is_admin(); 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("👤", "privileges:", &"no admin".dimmed().to_string());
display::info_line("🖥", "device:", &label.dimmed().to_string()); display::info_line("🖥", "device:", &label.dimmed().to_string());
display::info_line("🪪", "session:", &sid.to_string().dimmed().to_string());
println!(); 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct Config { struct Config {
relay_url: String, relay_url: String,
api_key: String, api_key: String,
label: Option<String>, label: String,
session_id: Option<String>, // persistent UUID
} }
impl Config { impl Config {
@ -131,39 +181,73 @@ fn prompt_config() -> Config {
}; };
let label = { let label = {
let default_label = hostname(); let default_label = sanitize_label(&hostname());
print!(" {} Label for this PC [{}]: ", "".cyan().bold(), default_label); loop {
print!(" {} Device label [{}]: ", "".cyan().bold(), default_label);
std::io::stdout().flush().unwrap(); std::io::stdout().flush().unwrap();
let mut input = String::new(); let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap(); std::io::stdin().read_line(&mut input).unwrap();
let trimmed = input.trim().to_string(); let trimmed = input.trim();
if trimmed.is_empty() { let candidate = if trimmed.is_empty() {
Some(default_label) default_label.clone()
} else { } 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] #[tokio::main]
async fn main() { async fn main() {
// Enable ANSI color codes on Windows (required when running as admin)
#[cfg(windows)] #[cfg(windows)]
enable_ansi(); enable_ansi();
logger::init(); logger::init();
// Suppress tracing output by default
if std::env::var("RUST_LOG").is_err() { if std::env::var("RUST_LOG").is_err() {
unsafe { std::env::set_var("RUST_LOG", "off"); } unsafe { std::env::set_var("RUST_LOG", "off"); }
} }
banner(); 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 // Load or prompt for config
let config = match Config::load() { 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 => { None => {
display::info_line("", "setup:", "No config found — first-time setup"); display::info_line("", "setup:", "No config found — first-time setup");
println!(); println!();
@ -178,22 +262,8 @@ async fn main() {
} }
}; };
// Resolve or generate persistent session UUID let label = config.label.clone();
let sid: Uuid = match &config.session_id { print_device_info(&label);
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 config = Arc::new(config); let config = Arc::new(config);
let shell = Arc::new(Mutex::new(shell::PersistentShell::new())); let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
@ -225,9 +295,9 @@ async fn main() {
let (mut write, mut read) = ws_stream.split(); let (mut write, mut read) = ws_stream.split();
// Send Hello // Send Hello with device label
let hello = ClientMessage::Hello { let hello = ClientMessage::Hello {
label: config.label.clone(), label: label.clone(),
}; };
let hello_json = serde_json::to_string(&hello).unwrap(); let hello_json = serde_json::to_string(&hello).unwrap();
if let Err(e) = write.send(Message::Text(hello_json)).await { if let Err(e) = write.send(Message::Text(hello_json)).await {
@ -254,9 +324,6 @@ async fn main() {
let shell_clone = Arc::clone(&shell); let shell_clone = Arc::clone(&shell);
tokio::spawn(async move { 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 response = handle_message(server_msg, shell_clone).await;
let json = match serde_json::to_string(&response) { let json = match serde_json::to_string(&response) {
Ok(j) => j, Ok(j) => j,
@ -343,14 +410,14 @@ async fn handle_message(
} }
ServerMessage::ScreenshotRequest { request_id } => { ServerMessage::ScreenshotRequest { request_id } => {
display::cmd_start("📷", "screenshot", ""); display::cmd_start("📷", "screenshot", "screen");
match screenshot::take_screenshot() { match screenshot::take_screenshot() {
Ok((image_base64, width, height)) => { 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 } ClientMessage::ScreenshotResponse { request_id, image_base64, width, height }
} }
Err(e) => { 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}") } ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") }
} }
} }
@ -358,7 +425,6 @@ async fn handle_message(
ServerMessage::PromptRequest { request_id, message, title: _ } => { ServerMessage::PromptRequest { request_id, message, title: _ } => {
display::prompt_waiting(&message); display::prompt_waiting(&message);
// Read user input from stdin (blocking)
let answer = tokio::task::spawn_blocking(|| { let answer = tokio::task::spawn_blocking(|| {
let mut input = String::new(); let mut input = String::new();
std::io::stdin().read_line(&mut input).ok(); std::io::stdin().read_line(&mut input).ok();
@ -375,8 +441,6 @@ async fn handle_message(
match sh.run(&command, timeout_ms).await { match sh.run(&command, timeout_ms).await {
Ok((stdout, stderr, exit_code)) => { Ok((stdout, stderr, exit_code)) => {
let result = if exit_code != 0 { 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() let err_line = stderr.lines()
.map(|l| l.trim()) .map(|l| l.trim())
.find(|l| !l.is_empty() .find(|l| !l.is_empty()
@ -388,7 +452,6 @@ async fn handle_message(
.to_string(); .to_string();
err_line err_line
} else { } else {
// Success: first stdout line, no exit code
stdout.trim().lines().next().unwrap_or("").to_string() stdout.trim().lines().next().unwrap_or("").to_string()
}; };
display::cmd_done("", "execute", &payload, exit_code == 0, &result); 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 } => { ServerMessage::ListWindowsRequest { request_id } => {
display::cmd_start("🪟", "list windows", ""); display::cmd_start("🪟", "list windows", "");
match windows_mgmt::list_windows() { match windows_mgmt::list_windows() {
@ -610,7 +643,7 @@ async fn handle_message(
if let Some(rid) = request_id { if let Some(rid) = request_id {
ClientMessage::Ack { request_id: rid } ClientMessage::Ack { request_id: rid }
} else { } else {
ClientMessage::Hello { label: None } ClientMessage::Hello { label: String::new() }
} }
} }
} }

View file

@ -1,4 +1,4 @@
use helios_common::protocol::WindowInfo; use helios_common::protocol::{sanitize_label, WindowInfo};
// ── Windows implementation ────────────────────────────────────────────────── // ── Windows implementation ──────────────────────────────────────────────────
@ -14,7 +14,6 @@ mod win_impl {
keybd_event, KEYEVENTF_KEYUP, VK_MENU, keybd_event, KEYEVENTF_KEYUP, VK_MENU,
}; };
// Collect HWNDs via EnumWindows callback
unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL { unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let list = &mut *(lparam.0 as *mut Vec<HWND>); let list = &mut *(lparam.0 as *mut Vec<HWND>);
list.push(hwnd); list.push(hwnd);
@ -38,19 +37,29 @@ mod win_impl {
String::from_utf16_lossy(&buf[..len as usize]) 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> { pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
let hwnds = get_all_hwnds(); let hwnds = get_all_hwnds();
let mut windows = Vec::new(); let mut windows = Vec::new();
for hwnd in hwnds { for hwnd in hwnds {
let visible = unsafe { IsWindowVisible(hwnd).as_bool() }; let visible = unsafe { IsWindowVisible(hwnd).as_bool() };
let title = hwnd_title(hwnd); let title = hwnd_title(hwnd);
// Only return visible windows with a non-empty title
if !visible || title.is_empty() { if !visible || title.is_empty() {
continue; continue;
} }
let label = window_label(&title);
if label.is_empty() {
continue;
}
windows.push(WindowInfo { windows.push(WindowInfo {
id: hwnd.0 as u64, id: hwnd.0 as u64,
title, title,
label,
visible: true, visible: true,
}); });
} }
@ -71,9 +80,6 @@ mod win_impl {
Ok(()) 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) { 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, Default::default(), 0);
keybd_event(VK_MENU.0 as u8, 0, KEYEVENTF_KEYUP, 0); keybd_event(VK_MENU.0 as u8, 0, KEYEVENTF_KEYUP, 0);

View file

@ -1,27 +1,58 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
/// Information about a single window on the client machine /// Information about a single window on the client machine.
/// `label` is a human-readable, lowercase identifier (e.g. "google_chrome", "discord").
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo { pub struct WindowInfo {
pub id: u64, pub id: u64,
pub title: String, pub title: String,
pub label: String,
pub visible: bool, pub visible: bool,
} }
/// Validate a device/window label: lowercase, no whitespace, only a-z 0-9 - _
pub fn is_valid_label(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
/// Convert an arbitrary string into a valid label.
/// Lowercase, replace whitespace and invalid chars with '_', collapse runs.
pub fn sanitize_label(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut prev_underscore = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
result.push(c.to_ascii_lowercase());
prev_underscore = false;
} else if c == '-' {
result.push('-');
prev_underscore = false;
} else {
// Replace whitespace and other chars with _
if !prev_underscore && !result.is_empty() {
result.push('_');
prev_underscore = true;
}
}
}
// Trim trailing _
result.trim_end_matches('_').to_string()
}
/// Messages sent from the relay server to a connected client /// Messages sent from the relay server to a connected client
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage { pub enum ServerMessage {
/// Request a screenshot from the client /// Request a full-screen screenshot
ScreenshotRequest { request_id: Uuid }, ScreenshotRequest { request_id: Uuid },
/// Capture a specific window by its HWND (works even if behind other windows) /// Capture a specific window by its HWND
WindowScreenshotRequest { request_id: Uuid, window_id: u64 }, WindowScreenshotRequest { request_id: Uuid, window_id: u64 },
/// Fetch the last N lines of the client log file /// Fetch the last N lines of the client log file
LogsRequest { request_id: Uuid, lines: u32 }, LogsRequest { request_id: Uuid, lines: u32 },
/// Show a MessageBox on the client asking the user to do something. /// 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.
PromptRequest { PromptRequest {
request_id: Uuid, request_id: Uuid,
message: String, message: String,
@ -31,21 +62,8 @@ pub enum ServerMessage {
ExecRequest { ExecRequest {
request_id: Uuid, request_id: Uuid,
command: String, command: String,
/// Timeout in milliseconds. None = use client default (30s)
timeout_ms: Option<u64>, timeout_ms: Option<u64>,
}, },
/// Simulate a mouse click
ClickRequest {
request_id: Uuid,
x: i32,
y: i32,
button: MouseButton,
},
/// Type text on the client
TypeRequest {
request_id: Uuid,
text: String,
},
/// Acknowledge a client message /// Acknowledge a client message
Ack { request_id: Uuid }, Ack { request_id: Uuid },
/// Server-side error response /// Server-side error response
@ -90,8 +108,8 @@ pub enum ServerMessage {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage { pub enum ClientMessage {
/// Client registers itself with optional display name /// Client registers itself with its device label
Hello { label: Option<String> }, Hello { label: String },
/// Response to a screenshot request — base64-encoded PNG /// Response to a screenshot request — base64-encoded PNG
ScreenshotResponse { ScreenshotResponse {
request_id: Uuid, request_id: Uuid,
@ -106,7 +124,7 @@ pub enum ClientMessage {
stderr: String, stderr: String,
exit_code: i32, exit_code: i32,
}, },
/// Generic acknowledgement for click/type/minimize-all/focus/maximize /// Generic acknowledgement
Ack { request_id: Uuid }, Ack { request_id: Uuid },
/// Client error response /// Client error response
Error { Error {
@ -137,29 +155,33 @@ pub enum ClientMessage {
}, },
/// Response to a clipboard-get request /// Response to a clipboard-get request
ClipboardGetResponse { request_id: Uuid, text: String }, 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 }, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_valid_labels() {
assert!(is_valid_label("moritz_pc"));
assert!(is_valid_label("my-desktop"));
assert!(is_valid_label("pc01"));
assert!(!is_valid_label("Moritz PC"));
assert!(!is_valid_label(""));
assert!(!is_valid_label("has spaces"));
assert!(!is_valid_label("UPPER"));
}
#[test]
fn test_sanitize_label() {
assert_eq!(sanitize_label("Moritz PC"), "moritz_pc");
assert_eq!(sanitize_label("My Desktop!!"), "my_desktop");
assert_eq!(sanitize_label("hello-world"), "hello-world");
assert_eq!(sanitize_label("DESKTOP-ABC123"), "desktop-abc123");
}
#[test] #[test]
fn test_server_message_serialization() { fn test_server_message_serialization() {
let msg = ServerMessage::ExecRequest { let msg = ServerMessage::ExecRequest {
@ -174,25 +196,9 @@ mod tests {
#[test] #[test]
fn test_client_message_serialization() { fn test_client_message_serialization() {
let msg = ClientMessage::Hello { label: Some("test-pc".into()) }; let msg = ClientMessage::Hello { label: "test-pc".into() };
let json = serde_json::to_string(&msg).unwrap(); let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("hello")); assert!(json.contains("hello"));
assert!(json.contains("test-pc")); assert!(json.contains("test-pc"));
} }
#[test]
fn test_roundtrip() {
let msg = ClientMessage::ExecResponse {
request_id: Uuid::nil(),
stdout: "hello\n".into(),
stderr: String::new(),
exit_code: 0,
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: ClientMessage = serde_json::from_str(&json).unwrap();
match decoded {
ClientMessage::ExecResponse { exit_code, .. } => assert_eq!(exit_code, 0),
_ => panic!("wrong variant"),
}
}
} }

View file

@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use tracing::error; use tracing::error;
use helios_common::protocol::{ClientMessage, MouseButton, ServerMessage}; use helios_common::protocol::{ClientMessage, ServerMessage};
use crate::AppState; use crate::AppState;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
@ -21,33 +21,29 @@ pub struct ErrorBody {
pub error: String, pub error: String,
} }
fn not_found(session_id: &str) -> (StatusCode, Json<ErrorBody>) { fn not_found(label: &str) -> (StatusCode, Json<ErrorBody>) {
( (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(ErrorBody { Json(ErrorBody {
error: format!("Session '{session_id}' not found or not connected"), error: format!("Device '{label}' not found or not connected"),
}), }),
) )
} }
fn timeout_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) { fn timeout_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
( (
StatusCode::GATEWAY_TIMEOUT, StatusCode::GATEWAY_TIMEOUT,
Json(ErrorBody { Json(ErrorBody {
error: format!( error: format!("Timed out waiting for client response (device='{label}', op='{op}')"),
"Timed out waiting for client response (session='{session_id}', op='{op}')"
),
}), }),
) )
} }
fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) { fn send_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
( (
StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY,
Json(ErrorBody { Json(ErrorBody {
error: format!( error: format!("Failed to send command to client — may have disconnected (device='{label}', op='{op}')"),
"Failed to send command to client — client may have disconnected (session='{session_id}', op='{op}')"
),
}), }),
) )
} }
@ -56,19 +52,19 @@ fn send_error(session_id: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
async fn dispatch<F>( async fn dispatch<F>(
state: &AppState, state: &AppState,
session_id: &str, label: &str,
op: &str, op: &str,
make_msg: F, make_msg: F,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)> ) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where where
F: FnOnce(Uuid) -> ServerMessage, F: FnOnce(Uuid) -> ServerMessage,
{ {
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>( async fn dispatch_with_timeout<F>(
state: &AppState, state: &AppState,
session_id: &str, label: &str,
op: &str, op: &str,
make_msg: F, make_msg: F,
timeout: Duration, timeout: Duration,
@ -76,50 +72,62 @@ async fn dispatch_with_timeout<F>(
where where
F: FnOnce(Uuid) -> ServerMessage, 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 let tx = state
.sessions .sessions
.get_cmd_tx(&id) .get_cmd_tx(label)
.ok_or_else(|| not_found(session_id))?; .ok_or_else(|| not_found(label))?;
let request_id = Uuid::new_v4(); let request_id = Uuid::new_v4();
let rx = state.sessions.register_pending(request_id); let rx = state.sessions.register_pending(request_id);
let msg = make_msg(request_id); let msg = make_msg(request_id);
tx.send(msg).await.map_err(|e| { tx.send(msg).await.map_err(|e| {
error!("Channel send failed for session={session_id}, op={op}: {e}"); error!("Channel send failed for device={label}, op={op}: {e}");
send_error(session_id, op) send_error(label, op)
})?; })?;
match tokio::time::timeout(timeout, rx).await { match tokio::time::timeout(timeout, rx).await {
Ok(Ok(response)) => Ok(response), Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(send_error(session_id, op)), Ok(Err(_)) => Err(send_error(label, op)),
Err(_) => Err(timeout_error(session_id, op)), Err(_) => Err(timeout_error(label, op)),
} }
} }
// ── Handlers ───────────────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────────
/// GET /sessions — list all connected clients /// GET /devices — list all connected clients
pub async fn list_sessions(State(state): State<AppState>) -> Json<serde_json::Value> { pub async fn list_devices(State(state): State<AppState>) -> Json<serde_json::Value> {
let sessions = state.sessions.list(); let devices = state.sessions.list();
Json(serde_json::json!({ "sessions": sessions })) Json(serde_json::json!({ "devices": devices }))
} }
/// POST /sessions/:id/windows/:window_id/screenshot /// POST /devices/:label/screenshot — full screen screenshot
pub async fn window_screenshot( pub async fn request_screenshot(
Path((session_id, window_id)): Path<(String, u64)>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> 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 } ServerMessage::WindowScreenshotRequest { request_id: rid, window_id }
}).await { }).await {
Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => ( 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( pub async fn logs(
Path(session_id): Path<String>, Path(label): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>, Query(params): Query<std::collections::HashMap<String, String>>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let lines: u32 = params.get("lines").and_then(|v| v.parse().ok()).unwrap_or(100); 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 } ServerMessage::LogsRequest { request_id: rid, lines }
}).await { }).await {
Ok(ClientMessage::LogsResponse { content, log_path, .. }) => ( Ok(ClientMessage::LogsResponse { content, log_path, .. }) => (
@ -158,216 +166,95 @@ pub async fn logs(
} }
} }
/// POST /sessions/:id/screenshot /// POST /devices/:label/exec
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
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ExecBody { pub struct ExecBody {
pub command: String, pub command: String,
/// Optional timeout in milliseconds (default: 30000). Use higher values for
/// long-running commands like downloads.
pub timeout_ms: Option<u64>, pub timeout_ms: Option<u64>,
} }
pub async fn request_exec( pub async fn request_exec(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<ExecBody>, Json(body): Json<ExecBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Server-side wait must be at least as long as the client timeout + buffer
let server_timeout = body.timeout_ms 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); .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, request_id: rid,
command: body.command.clone(), command: body.command.clone(),
timeout_ms: body.timeout_ms, timeout_ms: body.timeout_ms,
}, server_timeout) }, server_timeout).await {
.await Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => (
{
Ok(ClientMessage::ExecResponse {
stdout,
stderr,
exit_code,
..
}) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ Json(serde_json::json!({ "stdout": stdout, "stderr": stderr, "exit_code": exit_code })),
"stdout": stdout, ).into_response(),
"stderr": stderr,
"exit_code": exit_code,
})),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/click /// GET /devices/:label/windows
#[derive(Deserialize)]
pub struct ClickBody {
pub x: i32,
pub y: i32,
#[serde(default)]
pub button: MouseButton,
}
pub async fn request_click(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<ClickBody>,
) -> impl IntoResponse {
match dispatch(&state, &session_id, "click", |rid| ServerMessage::ClickRequest {
request_id: rid,
x: body.x,
y: body.y,
button: body.button.clone(),
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /sessions/:id/type
#[derive(Deserialize)]
pub struct TypeBody {
pub text: String,
}
pub async fn request_type(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<TypeBody>,
) -> impl IntoResponse {
match dispatch(&state, &session_id, "type", |rid| ServerMessage::TypeRequest {
request_id: rid,
text: body.text.clone(),
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /sessions/:id/windows
pub async fn list_windows( pub async fn list_windows(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "list_windows", |rid| { match dispatch(&state, &label, "list_windows", |rid| {
ServerMessage::ListWindowsRequest { request_id: rid } ServerMessage::ListWindowsRequest { request_id: rid }
}) }).await {
.await
{
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => ( Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "windows": windows })), Json(serde_json::json!({ "windows": windows })),
) ).into_response(),
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/windows/minimize-all /// POST /devices/:label/windows/minimize-all
pub async fn minimize_all( pub async fn minimize_all(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "minimize_all", |rid| { match dispatch(&state, &label, "minimize_all", |rid| {
ServerMessage::MinimizeAllRequest { request_id: rid } ServerMessage::MinimizeAllRequest { request_id: rid }
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/windows/:window_id/focus /// POST /devices/:label/windows/:window_id/focus
pub async fn focus_window( pub async fn focus_window(
Path((session_id, window_id)): Path<(String, u64)>, Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "focus_window", |rid| { match dispatch(&state, &label, "focus_window", |rid| {
ServerMessage::FocusWindowRequest { request_id: rid, window_id } ServerMessage::FocusWindowRequest { request_id: rid, window_id }
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/windows/:window_id/maximize /// POST /devices/:label/windows/:window_id/maximize
pub async fn maximize_and_focus( pub async fn maximize_and_focus(
Path((session_id, window_id)): Path<(String, u64)>, Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "maximize_and_focus", |rid| { match dispatch(&state, &label, "maximize_and_focus", |rid| {
ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id } ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
@ -376,41 +263,32 @@ pub async fn maximize_and_focus(
/// GET /version — server version (public, no auth) /// GET /version — server version (public, no auth)
pub async fn server_version() -> impl IntoResponse { pub async fn server_version() -> impl IntoResponse {
Json(serde_json::json!({ Json(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"commit": env!("GIT_COMMIT"), "commit": env!("GIT_COMMIT"),
})) }))
} }
/// GET /sessions/:id/version — client version /// GET /devices/:label/version — client version
pub async fn client_version( pub async fn client_version(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "version", |rid| { match dispatch(&state, &label, "version", |rid| {
ServerMessage::VersionRequest { request_id: rid } ServerMessage::VersionRequest { request_id: rid }
}) }).await {
.await
{
Ok(ClientMessage::VersionResponse { version, commit, .. }) => ( Ok(ClientMessage::VersionResponse { version, commit, .. }) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "version": version, "commit": commit })), Json(serde_json::json!({ "version": version, "commit": commit })),
) ).into_response(),
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/upload /// POST /devices/:label/upload
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UploadBody { pub struct UploadBody {
pub path: String, pub path: String,
@ -418,59 +296,49 @@ pub struct UploadBody {
} }
pub async fn upload_file( pub async fn upload_file(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<UploadBody>, Json(body): Json<UploadBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "upload", |rid| ServerMessage::UploadRequest { match dispatch(&state, &label, "upload", |rid| ServerMessage::UploadRequest {
request_id: rid, request_id: rid,
path: body.path.clone(), path: body.path.clone(),
content_base64: body.content_base64.clone(), content_base64: body.content_base64.clone(),
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// GET /sessions/:id/download?path=... /// GET /devices/:label/download?path=...
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DownloadQuery { pub struct DownloadQuery {
pub path: String, pub path: String,
} }
pub async fn download_file( pub async fn download_file(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<DownloadQuery>, Query(query): Query<DownloadQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "download", |rid| ServerMessage::DownloadRequest { match dispatch(&state, &label, "download", |rid| ServerMessage::DownloadRequest {
request_id: rid, request_id: rid,
path: query.path.clone(), path: query.path.clone(),
}) }).await {
.await
{
Ok(ClientMessage::DownloadResponse { content_base64, size, .. }) => ( Ok(ClientMessage::DownloadResponse { content_base64, size, .. }) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "content_base64": content_base64, "size": size })), Json(serde_json::json!({ "content_base64": content_base64, "size": size })),
) ).into_response(),
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/run /// POST /devices/:label/run
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct RunBody { pub struct RunBody {
pub program: String, pub program: String,
@ -479,73 +347,61 @@ pub struct RunBody {
} }
pub async fn run_program( pub async fn run_program(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<RunBody>, Json(body): Json<RunBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "run", |rid| ServerMessage::RunRequest { match dispatch(&state, &label, "run", |rid| ServerMessage::RunRequest {
request_id: rid, request_id: rid,
program: body.program.clone(), program: body.program.clone(),
args: body.args.clone(), args: body.args.clone(),
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// GET /sessions/:id/clipboard /// GET /devices/:label/clipboard
pub async fn clipboard_get( pub async fn clipboard_get(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "clipboard_get", |rid| { match dispatch(&state, &label, "clipboard_get", |rid| {
ServerMessage::ClipboardGetRequest { request_id: rid } ServerMessage::ClipboardGetRequest { request_id: rid }
}) }).await {
.await
{
Ok(ClientMessage::ClipboardGetResponse { text, .. }) => ( Ok(ClientMessage::ClipboardGetResponse { text, .. }) => (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "text": text })), Json(serde_json::json!({ "text": text })),
) ).into_response(),
.into_response(),
Ok(ClientMessage::Error { message, .. }) => ( Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })), Json(serde_json::json!({ "error": message })),
) ).into_response(),
.into_response(), Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/clipboard /// POST /devices/:label/clipboard
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ClipboardSetBody { pub struct ClipboardSetBody {
pub text: String, pub text: String,
} }
pub async fn clipboard_set( pub async fn clipboard_set(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<ClipboardSetBody>, Json(body): Json<ClipboardSetBody>,
) -> impl IntoResponse { ) -> 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() } ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
}) }).await {
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(), Err(e) => e.into_response(),
} }
} }
/// POST /sessions/:id/prompt /// POST /devices/:label/prompt
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PromptBody { pub struct PromptBody {
pub message: String, pub message: String,
@ -553,17 +409,15 @@ pub struct PromptBody {
} }
pub async fn prompt_user( pub async fn prompt_user(
Path(session_id): Path<String>, Path(label): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<PromptBody>, Json(body): Json<PromptBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match dispatch(&state, &session_id, "prompt", |rid| ServerMessage::PromptRequest { match dispatch(&state, &label, "prompt", |rid| ServerMessage::PromptRequest {
request_id: rid, request_id: rid,
message: body.message.clone(), message: body.message.clone(),
title: body.title.clone(), title: body.title.clone(),
}) }).await {
.await
{
Ok(ClientMessage::PromptResponse { answer, .. }) => { Ok(ClientMessage::PromptResponse { answer, .. }) => {
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "answer": answer }))).into_response() (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(), 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()
}
}

View file

@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> {
.init(); .init();
const GIT_COMMIT: &str = env!("GIT_COMMIT"); 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") let api_key = std::env::var("HELIOS_API_KEY")
.unwrap_or_else(|_| "dev-secret".to_string()); .unwrap_or_else(|_| "dev-secret".to_string());
@ -45,25 +45,22 @@ async fn main() -> anyhow::Result<()> {
}; };
let protected = Router::new() let protected = Router::new()
.route("/sessions", get(api::list_sessions)) .route("/devices", get(api::list_devices))
.route("/sessions/:id/screenshot", post(api::request_screenshot)) .route("/devices/:label/screenshot", post(api::request_screenshot))
.route("/sessions/:id/exec", post(api::request_exec)) .route("/devices/:label/exec", post(api::request_exec))
.route("/sessions/:id/click", post(api::request_click)) .route("/devices/:label/prompt", post(api::prompt_user))
.route("/sessions/:id/type", post(api::request_type)) .route("/devices/:label/windows", get(api::list_windows))
.route("/sessions/:id/label", post(api::set_label)) .route("/devices/:label/windows/minimize-all", post(api::minimize_all))
.route("/sessions/:id/prompt", post(api::prompt_user)) .route("/devices/:label/logs", get(api::logs))
.route("/sessions/:id/windows", get(api::list_windows)) .route("/devices/:label/windows/:window_id/screenshot", post(api::window_screenshot))
.route("/sessions/:id/windows/minimize-all", post(api::minimize_all)) .route("/devices/:label/windows/:window_id/focus", post(api::focus_window))
.route("/sessions/:id/logs", get(api::logs)) .route("/devices/:label/windows/:window_id/maximize", post(api::maximize_and_focus))
.route("/sessions/:id/windows/:window_id/screenshot", post(api::window_screenshot)) .route("/devices/:label/version", get(api::client_version))
.route("/sessions/:id/windows/:window_id/focus", post(api::focus_window)) .route("/devices/:label/upload", post(api::upload_file))
.route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus)) .route("/devices/:label/download", get(api::download_file))
.route("/sessions/:id/version", get(api::client_version)) .route("/devices/:label/run", post(api::run_program))
.route("/sessions/:id/upload", post(api::upload_file)) .route("/devices/:label/clipboard", get(api::clipboard_get))
.route("/sessions/:id/download", get(api::download_file)) .route("/devices/:label/clipboard", post(api::clipboard_set))
.route("/sessions/:id/run", post(api::run_program))
.route("/sessions/:id/clipboard", get(api::clipboard_get))
.route("/sessions/:id/clipboard", post(api::clipboard_set))
.layer(middleware::from_fn_with_state(state.clone(), require_api_key)); .layer(middleware::from_fn_with_state(state.clone(), require_api_key));
let app = Router::new() let app = Router::new()

View file

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

View file

@ -19,32 +19,57 @@ pub async fn ws_upgrade(
} }
async fn handle_socket(socket: WebSocket, state: AppState) { async fn handle_socket(socket: WebSocket, state: AppState) {
let session_id = Uuid::new_v4();
let (cmd_tx, mut cmd_rx) = mpsc::channel::<ServerMessage>(64); let (cmd_tx, mut cmd_rx) = mpsc::channel::<ServerMessage>(64);
let (mut ws_tx, mut ws_rx) = socket.split();
// Register session (label filled in on Hello) // Wait for the Hello message to get the device label
let label = loop {
match ws_rx.next().await {
Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Hello { label }) => {
if label.is_empty() {
warn!("Client sent empty label, disconnecting");
return;
}
break label;
}
Ok(_) => {
warn!("Expected Hello as first message, got something else");
return;
}
Err(e) => {
warn!("Invalid JSON on handshake: {e}");
return;
}
}
}
Some(Ok(Message::Close(_))) | None => return,
_ => continue,
}
};
// Register session by label
let session = Session { let session = Session {
id: session_id, label: label.clone(),
label: None,
cmd_tx, cmd_tx,
}; };
state.sessions.insert(session); state.sessions.insert(session);
info!("Client connected: session={session_id}"); info!("Client connected: device={label}");
let (mut ws_tx, mut ws_rx) = socket.split();
// Spawn task: forward server commands → WS // Spawn task: forward server commands → WS
let label_clone = label.clone();
let send_task = tokio::spawn(async move { let send_task = tokio::spawn(async move {
while let Some(msg) = cmd_rx.recv().await { while let Some(msg) = cmd_rx.recv().await {
match serde_json::to_string(&msg) { match serde_json::to_string(&msg) {
Ok(json) => { Ok(json) => {
if let Err(e) = ws_tx.send(Message::Text(json.into())).await { if let Err(e) = ws_tx.send(Message::Text(json.into())).await {
error!("WS send error for session={session_id}: {e}"); error!("WS send error for device={label_clone}: {e}");
break; break;
} }
} }
Err(e) => { Err(e) => {
error!("Serialization error for session={session_id}: {e}"); error!("Serialization error for device={label_clone}: {e}");
} }
} }
} }
@ -55,36 +80,33 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
match result { match result {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
match serde_json::from_str::<ClientMessage>(&text) { match serde_json::from_str::<ClientMessage>(&text) {
Ok(msg) => handle_client_message(session_id, msg, &state).await, Ok(msg) => handle_client_message(&label, msg, &state).await,
Err(e) => { Err(e) => {
warn!("Invalid JSON from session={session_id}: {e} | raw={text}"); warn!("Invalid JSON from device={label}: {e}");
} }
} }
} }
Ok(Message::Close(_)) => { Ok(Message::Close(_)) => {
info!("Client disconnected gracefully: session={session_id}"); info!("Client disconnected gracefully: device={label}");
break; break;
} }
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {} Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Binary(_)) => {}
Err(e) => { Err(e) => {
error!("WS receive error for session={session_id}: {e}"); error!("WS receive error for device={label}: {e}");
break; break;
} }
} }
} }
send_task.abort(); send_task.abort();
state.sessions.remove(&session_id); state.sessions.remove(&label);
info!("Session cleaned up: session={session_id}"); info!("Session cleaned up: device={label}");
} }
async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &AppState) { async fn handle_client_message(label: &str, msg: ClientMessage, state: &AppState) {
match &msg { match &msg {
ClientMessage::Hello { label } => { ClientMessage::Hello { .. } => {
if let Some(lbl) = label { debug!("Duplicate Hello from device={label}, ignoring");
state.sessions.set_label(&session_id, lbl.clone());
}
debug!("Hello from session={session_id}, label={label:?}");
} }
ClientMessage::ScreenshotResponse { request_id, .. } ClientMessage::ScreenshotResponse { request_id, .. }
| ClientMessage::ExecResponse { request_id, .. } | ClientMessage::ExecResponse { request_id, .. }
@ -98,7 +120,7 @@ async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &App
| ClientMessage::Error { request_id, .. } => { | ClientMessage::Error { request_id, .. } => {
let rid = *request_id; let rid = *request_id;
if !state.sessions.resolve_pending(rid, msg) { if !state.sessions.resolve_pending(rid, msg) {
warn!("No pending request for request_id={rid} (session={session_id})"); warn!("No pending request for request_id={rid} (device={label})");
} }
} }
} }

499
remote.py
View file

@ -1,12 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
helios-remote - CLI to control PCs connected to the Helios Remote Relay Server. helios-remote CLI - Control devices connected to the Helios Remote Relay Server.
Config is loaded from config.env in the same directory as this script.
Device labels are the sole identifiers. No session UUIDs.
Labels must be lowercase, no whitespace, only a-z 0-9 - _
""" """
import argparse import argparse
import base64 import base64
import os import os
import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -32,6 +35,18 @@ except ImportError:
sys.exit("[helios-remote] ERROR: 'requests' not installed. Run: pip install requests") 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 ───────────────────────────────────────────────────────────── # ── HTTP helpers ─────────────────────────────────────────────────────────────
def _headers() -> dict: def _headers() -> dict:
@ -58,111 +73,85 @@ def _req(method: str, path: str, timeout: int = 30, **kwargs):
return resp return resp
# ── Subcommands ────────────────────────────────────────────────────────────── # ── Window resolution ────────────────────────────────────────────────────────
HEADERS = _headers # alias for lazy evaluation 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")
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")
windows = resp.json().get("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()] matches = [w for w in windows if w.get("visible") and query in w.get("title", "").lower()]
if not matches: 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: 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: for w in matches:
print(f" {w['id']} {w['title']}") print(f" {w.get('label', '?'):<30} {w['title']}")
return int(matches[0]["id"]) return int(matches[0]["id"])
def cmd_sessions(_args): # ── Commands ─────────────────────────────────────────────────────────────────
"""List all connected sessions."""
resp = _req("GET", "/sessions") def cmd_devices(_args):
"""List all connected devices."""
resp = _req("GET", "/devices")
data = resp.json() data = resp.json()
sessions = data if isinstance(data, list) else data.get("sessions", []) devices = data.get("devices", [])
if not devices:
if not sessions: print("No devices connected.")
print("No sessions connected.")
return return
print(f"{'Device':<30}")
print(f"{'ID':<36} {'Label':<20} Info") print("-" * 30)
print("-" * 70) for d in devices:
for s in sessions: print(d.get("label", "?"))
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}.")
def cmd_screenshot(args): def cmd_screenshot(args):
"""Capture a screenshot → /tmp/helios-remote-screenshot.png""" """Capture a screenshot. Argument is 'screen' for full screen or a window label."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
resp = _req("POST", f"/sessions/{sid}/screenshot") target = args.target
out_path = Path("/tmp/helios-remote-screenshot.png") out_path = Path("/tmp/helios-remote-screenshot.png")
content_type = resp.headers.get("Content-Type", "") if target == "screen":
if "image" in content_type: resp = _req("POST", f"/devices/{device}/screenshot")
out_path.write_bytes(resp.content)
else: else:
# Resolve window label to HWND
wid = resolve_window(device, target)
resp = _req("POST", f"/devices/{device}/windows/{wid}/screenshot")
data = resp.json() data = resp.json()
b64 = ( b64 = (
data.get("screenshot") data.get("image_base64")
or data.get("screenshot")
or data.get("image") or data.get("image")
or data.get("image_base64")
or data.get("data") or data.get("data")
or data.get("png") or data.get("png")
) )
if not b64: if not b64:
sys.exit( sys.exit(f"[helios-remote] ERROR: No image in response. Keys: {list(data.keys())}")
f"[helios-remote] ERROR: Screenshot response has no image field.\n" if "," in b64:
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,...)
b64 = b64.split(",", 1)[1] b64 = b64.split(",", 1)[1]
out_path.write_bytes(base64.b64decode(b64)) out_path.write_bytes(base64.b64decode(b64))
print(str(out_path)) print(str(out_path))
def cmd_exec(args): def cmd_exec(args):
"""Run a shell command on the remote session.""" """Run a shell command on the remote device."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts
body = {"command": command} body = {"command": command}
if args.timeout: if args.timeout:
body["timeout_ms"] = args.timeout * 1000 # seconds → ms body["timeout_ms"] = args.timeout * 1000
resp = _req("POST", f"/sessions/{sid}/exec", json=body, timeout=max(35, (args.timeout or 30) + 5)) resp = _req("POST", f"/devices/{device}/exec", json=body,
timeout=max(35, (args.timeout or 30) + 5))
data = resp.json() data = resp.json()
stdout = data.get("stdout") or data.get("output") or "" stdout = data.get("stdout") or data.get("output") or ""
@ -178,127 +167,78 @@ def cmd_exec(args):
sys.exit(int(exit_code)) 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): def cmd_windows(args):
"""List windows on the remote session (visible only by default).""" """List windows on the remote device (visible only)."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
resp = _req("GET", f"/sessions/{sid}/windows") resp = _req("GET", f"/devices/{device}/windows")
data = resp.json() data = resp.json()
windows = data.get("windows", []) 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")] windows = [w for w in windows if w.get("visible")]
if not windows: if not windows:
print("No windows returned.") print("No windows returned.")
return return
print(f"{'ID':<20} Title") print(f"{'Label':<30} Title")
print("-" * 70) print("-" * 70)
for w in windows: for w in windows:
wid = str(w.get("id", "?")) label = w.get("label", "?")
title = w.get("title", "") title = w.get("title", "")
print(f"{wid:<20} {title}") print(f"{label:<30} {title}")
def cmd_minimize_all(args): def cmd_minimize_all(args):
"""Minimize all windows on the remote session.""" """Minimize all windows on the remote device."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
_req("POST", f"/sessions/{sid}/windows/minimize-all") _req("POST", f"/devices/{device}/windows/minimize-all")
print(f"All windows minimized on session {sid!r}.") print(f"All windows minimized on {device}.")
def cmd_focus(args): def cmd_focus(args):
"""Bring a window to the foreground (by ID or title substring).""" """Bring a window to the foreground (by label)."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
wid = resolve_window(sid, args.window_id) wid = resolve_window(device, args.window)
_req("POST", f"/sessions/{sid}/windows/{wid}/focus") _req("POST", f"/devices/{device}/windows/{wid}/focus")
print(f"Window {wid} focused on session {sid!r}.") print(f"Window '{args.window}' focused on {device}.")
def cmd_maximize(args): def cmd_maximize(args):
"""Maximize and focus a window (by ID or title substring).""" """Maximize and focus a window (by label)."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
wid = resolve_window(sid, args.window_id) wid = resolve_window(device, args.window)
_req("POST", f"/sessions/{sid}/windows/{wid}/maximize") _req("POST", f"/devices/{device}/windows/{wid}/maximize")
print(f"Window {wid} maximized on session {sid!r}.") print(f"Window '{args.window}' maximized on {device}.")
def cmd_logs(args): def cmd_version(args):
"""Fetch the last N lines of the client log file.""" """Show relay, remote.py, and client commit — check if all are in sync."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
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", ""))
# 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): # 2. remote.py commit
"""Capture a specific window by ID or title substring → /tmp/helios-remote-screenshot.png""" import subprocess
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
try: try:
r = subprocess.run( r = subprocess.run(
["git", "log", "-1", "--format=%h", "--", __file__], ["git", "log", "-1", "--format=%h", "--", __file__],
capture_output=True, text=True, capture_output=True, text=True,
cwd=os.path.dirname(os.path.abspath(__file__)) cwd=os.path.dirname(os.path.abspath(__file__))
) )
return r.stdout.strip() or "unknown" script_commit = r.stdout.strip() or "unknown"
except Exception: except Exception:
return "unknown" script_commit = "unknown"
# 3. Client
def cmd_status(args):
"""Show relay, remote.py, and client commit in one call."""
import requests as _requests
# 1. Relay (public endpoint, no auth)
try: try:
r = _requests.get(f"{BASE_URL}/version", timeout=10) r2 = _req("GET", f"/devices/{device}/version")
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")
client = r2.json() client = r2.json()
except Exception as e: except SystemExit:
client = {"commit": str(e)} client = {"commit": "unreachable"}
relay_commit = relay.get("commit", "?") relay_commit = relay.get("commit", "?")
script_commit = _script_commit()
client_commit = client.get("commit", "?") client_commit = client.get("commit", "?")
all_same = relay_commit == script_commit == client_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'}") print(f" {'✅ all in sync' if all_same else '⚠️ OUT OF SYNC'}")
def cmd_server_version(_args): def cmd_logs(args):
"""Get server version (no auth required).""" """Fetch the last N lines of the client log file."""
import requests as _requests device = validate_label(args.device)
try: resp = _req("GET", f"/devices/{device}/logs", params={"lines": args.lines})
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]}")
data = resp.json() data = resp.json()
print(f"Server version : {data.get('version', '?')}") if "error" in data:
print(f"Commit : {data.get('commit', '?')}") 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):
"""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', '?')}")
def cmd_upload(args): def cmd_upload(args):
"""Upload a local file to the remote session.""" """Upload a local file to the remote device."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
local_path = Path(args.local_path) local_path = Path(args.local_path)
if not local_path.exists(): if not local_path.exists():
sys.exit(f"[helios-remote] ERROR: Local file not found: {local_path}") sys.exit(f"[helios-remote] ERROR: Local file not found: {local_path}")
content_base64 = base64.b64encode(local_path.read_bytes()).decode() 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, "path": args.remote_path,
"content_base64": content_base64, "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): def cmd_download(args):
"""Download a file from the remote session to a local path.""" """Download a file from the remote device to a local path."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
from urllib.parse import quote from urllib.parse import quote
encoded_path = quote(args.remote_path, safe="") 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() data = resp.json()
b64 = data.get("content_base64", "") b64 = data.get("content_base64", "")
if not b64: 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 = Path(args.local_path)
local_path.parent.mkdir(parents=True, exist_ok=True) local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(base64.b64decode(b64)) 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).") 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): def cmd_prompt(args):
"""Show a MessageBox on the remote PC asking the user to do something. """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 device = validate_label(args.device)
to perform a manual action (e.g. click a button, confirm a dialog)."""
sid = resolve_session(args.session_id)
body = {"message": args.message} body = {"message": args.message}
if args.title: if args.title:
body["title"] = args.title body["title"] = args.title
resp = _req("POST", f"/sessions/{sid}/prompt", json=body) resp = _req("POST", f"/devices/{device}/prompt", json=body)
answer = resp.get("answer", "") data = resp.json()
answer = data.get("answer", "")
if answer: if answer:
print(answer) print(answer)
else: else:
print(f"Prompt confirmed (no answer returned).") print("Prompt confirmed.")
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")
def cmd_run(args): def cmd_run(args):
"""Launch a program on the remote session (fire-and-forget).""" """Launch a program on the remote device (fire-and-forget)."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
_req("POST", f"/sessions/{sid}/run", json={"program": args.program, "args": args.args}) _req("POST", f"/devices/{device}/run", json={"program": args.program, "args": args.args})
print(f"Started {args.program!r} on session {sid!r}.") print(f"Started {args.program!r} on {device}.")
def cmd_clipboard_get(args): def cmd_clipboard_get(args):
"""Get the clipboard contents from the remote session.""" """Get the clipboard contents from the remote device."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
resp = _req("GET", f"/sessions/{sid}/clipboard") resp = _req("GET", f"/devices/{device}/clipboard")
data = resp.json() data = resp.json()
print(data.get("text", "")) print(data.get("text", ""))
def cmd_clipboard_set(args): def cmd_clipboard_set(args):
"""Set the clipboard contents on the remote session.""" """Set the clipboard contents on the remote device."""
sid = resolve_session(args.session_id) device = validate_label(args.device)
_req("POST", f"/sessions/{sid}/clipboard", json={"text": args.text}) _req("POST", f"/devices/{device}/clipboard", json={"text": args.text})
print(f"Clipboard set ({len(args.text)} chars) on session {sid!r}.") print(f"Clipboard set ({len(args.text)} chars) on {device}.")
# ── CLI wiring ──────────────────────────────────────────────────────────────── # ── CLI wiring ────────────────────────────────────────────────────────────────
@ -447,103 +332,69 @@ def cmd_clipboard_set(args):
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
prog="remote.py", 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 = 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") sp = sub.add_parser("screenshot", help="Capture screenshot (screen or window label)")
lp.add_argument("session_id") sp.add_argument("device", help="Device label")
lp.add_argument("name") 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") ep = sub.add_parser("exec", help="Run a shell command on the remote device")
sp.add_argument("session_id") ep.add_argument("device", help="Device label")
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.add_argument("parts", nargs=argparse.REMAINDER, metavar="command", ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command",
help="Command (and arguments) to execute") help="Command (and arguments) to execute")
ep.add_argument("--timeout", type=int, default=None, metavar="SECONDS", 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") wp = sub.add_parser("windows", help="List all visible windows on the remote device")
cp.add_argument("session_id") wp.add_argument("device", help="Device label")
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")
mp = sub.add_parser("minimize-all", help="Minimize all windows") 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 = sub.add_parser("focus", help="Bring a window to the foreground")
fp.add_argument("session_id") fp.add_argument("device", help="Device label")
fp.add_argument("window_id") fp.add_argument("window", help="Window label")
xp = sub.add_parser("maximize", help="Maximize and focus a window") xp = sub.add_parser("maximize", help="Maximize and focus a window")
xp.add_argument("session_id") xp.add_argument("device", help="Device label")
xp.add_argument("window_id") xp.add_argument("window", help="Window label")
stp = sub.add_parser("status", help="Show relay + client commit and sync status") vp = sub.add_parser("version", help="Compare relay, remote.py, and client commits")
stp.add_argument("session_id") vp.add_argument("device", help="Device label")
lgp = sub.add_parser("logs", help="Fetch last N lines of client log file") lgp = sub.add_parser("logs", help="Fetch last N lines of client log file")
lgp.add_argument("session_id") lgp.add_argument("device", help="Device label")
lgp.add_argument("--lines", type=int, default=100, metavar="N", help="Number of lines (default: 100)") lgp.add_argument("--lines", type=int, default=100, metavar="N")
sub.add_parser("server-version", help="Get server version (no auth required)") up = sub.add_parser("upload", help="Upload a local file to the remote device")
up.add_argument("device", help="Device label")
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.add_argument("local_path") up.add_argument("local_path")
up.add_argument("remote_path") up.add_argument("remote_path")
dp = sub.add_parser("download", help="Download a file from the remote session") dp = sub.add_parser("download", help="Download a file from the remote device")
dp.add_argument("session_id") dp.add_argument("device", help="Device label")
dp.add_argument("remote_path") dp.add_argument("remote_path")
dp.add_argument("local_path") dp.add_argument("local_path")
fwp = sub.add_parser("find-window", help="Find windows by title substring") pp = sub.add_parser("prompt", help="Show a MessageBox asking the user to do something")
fwp.add_argument("session_id") pp.add_argument("device", help="Device label")
fwp.add_argument("title", help="Substring to search for (case-insensitive)") 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") rp = sub.add_parser("run", help="Launch a program (fire-and-forget)")
wfwp.add_argument("session_id") rp.add_argument("device", help="Device label")
wfwp.add_argument("title", help="Substring to wait for (case-insensitive)") rp.add_argument("program")
wfwp.add_argument("--timeout", type=int, default=30, metavar="SECONDS", help="Max wait time (default: 30s)") rp.add_argument("args", nargs=argparse.REMAINDER)
wfwp.set_defaults(func=cmd_wait_for_window)
pp = sub.add_parser("prompt", help="Show a MessageBox asking the user to do something manually") cgp = sub.add_parser("clipboard-get", help="Get clipboard contents")
pp.add_argument("session_id") cgp.add_argument("device", help="Device label")
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)
rp = sub.add_parser("run", help="Launch a program on the remote session (fire-and-forget)") csp = sub.add_parser("clipboard-set", help="Set clipboard contents")
rp.add_argument("session_id") csp.add_argument("device", help="Device label")
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.add_argument("text") csp.add_argument("text")
return p return p
@ -554,27 +405,19 @@ def main():
args = parser.parse_args() args = parser.parse_args()
{ {
"sessions": cmd_sessions, "devices": cmd_devices,
"label": cmd_label,
"screenshot": cmd_screenshot, "screenshot": cmd_screenshot,
"exec": cmd_exec, "exec": cmd_exec,
"click": cmd_click,
"type": cmd_type,
"windows": cmd_windows, "windows": cmd_windows,
"minimize-all": cmd_minimize_all, "minimize-all": cmd_minimize_all,
"focus": cmd_focus, "focus": cmd_focus,
"maximize": cmd_maximize, "maximize": cmd_maximize,
"server-version": cmd_server_version,
"version": cmd_version, "version": cmd_version,
"logs": cmd_logs,
"upload": cmd_upload, "upload": cmd_upload,
"download": cmd_download, "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, "prompt": cmd_prompt,
"run": cmd_run,
"clipboard-get": cmd_clipboard_get, "clipboard-get": cmd_clipboard_get,
"clipboard-set": cmd_clipboard_set, "clipboard-set": cmd_clipboard_set,
}[args.subcmd](args) }[args.subcmd](args)

View file

@ -1,5 +1,5 @@
# Helios Remote - Install Script # Helios Remote - Install Script
# Downloads and starts the helios-remote Windows client # Downloads and starts the helios-remote Windows client.
# #
# IMPORTANT: Windows Defender may block the executable. # IMPORTANT: Windows Defender may block the executable.
# If the download or launch fails, disable real-time protection first (run as Admin): # If the download or launch fails, disable real-time protection first (run as Admin):
@ -14,7 +14,6 @@ $dest = "$env:USERPROFILE\Desktop\Helios Remote.exe"
Write-Host "Downloading helios-remote client..." 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) { if (Get-Command curl.exe -ErrorAction SilentlyContinue) {
curl.exe -L -o $dest $url curl.exe -L -o $dest $url
} else { } else {