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