feat: find-window, run, clipboard, label-routing, persistent session-id, exe icon
This commit is contained in:
parent
ef4ca0ccbb
commit
672676d3d7
9 changed files with 214 additions and 15 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -37,8 +37,8 @@ jobs:
|
|||
with:
|
||||
targets: x86_64-pc-windows-gnu
|
||||
|
||||
- name: Install MinGW cross-compiler
|
||||
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64
|
||||
- name: Install MinGW cross-compiler and tools
|
||||
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
|
|
|||
45
README.md
45
README.md
|
|
@ -70,6 +70,16 @@ All endpoints require the `X-Api-Key` header.
|
|||
| `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 |
|
||||
|
||||
### WebSocket
|
||||
|
||||
|
|
@ -112,6 +122,41 @@ curl -s -X POST -H "X-Api-Key: your-secret-key" \
|
|||
http://localhost:3000/sessions/<session-id>/click
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
python remote.py sessions # list sessions
|
||||
python remote.py screenshot <session> # capture screenshot → /tmp/helios-remote-screenshot.png
|
||||
python remote.py exec <session> <command...> # run shell command
|
||||
python remote.py click <session> <x> <y> # mouse click
|
||||
python remote.py type <session> <text> # keyboard input
|
||||
python remote.py windows <session> # list windows
|
||||
python remote.py find-window <session> <title> # filter windows by title substring
|
||||
python remote.py minimize-all <session> # minimize all windows
|
||||
python remote.py focus <session> <window_id> # focus window
|
||||
python remote.py maximize <session> <window_id> # maximize and focus window
|
||||
python remote.py run <session> <program> [args...] # launch program (fire-and-forget)
|
||||
python remote.py clipboard-get <session> # get clipboard text
|
||||
python remote.py clipboard-set <session> <text> # set clipboard text
|
||||
python remote.py upload <session> <local> <remote> # upload file
|
||||
python remote.py download <session> <remote> <local> # download file
|
||||
python remote.py version <session> # client version
|
||||
python remote.py server-version # server version
|
||||
```
|
||||
|
||||
## Client (Phase 2)
|
||||
|
||||
See [`crates/client/README.md`](crates/client/README.md) for the planned Windows client implementation.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ serde_json = "1"
|
|||
toml = "0.8"
|
||||
chrono = "0.4"
|
||||
helios-common = { path = "../common" }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
dirs = "5"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
|
|
|||
|
|
@ -8,4 +8,15 @@ fn main() {
|
|||
let hash = hash.trim();
|
||||
println!("cargo:rustc-env=GIT_COMMIT={}", if hash.is_empty() { "unknown" } else { hash });
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
|
||||
// Embed Windows icon when cross-compiling for Windows
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set_icon("../../assets/logo.ico");
|
||||
// Set cross-compile toolkit (mingw-w64)
|
||||
res.set_toolkit_path("/usr");
|
||||
res.set_windres_path("x86_64-w64-mingw32-windres");
|
||||
res.set_ar_path("x86_64-w64-mingw32-ar");
|
||||
res.compile().unwrap_or_else(|e| eprintln!("winres warning: {e}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con
|
|||
|
||||
use base64::Engine;
|
||||
use helios_common::{ClientMessage, ServerMessage};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod shell;
|
||||
mod screenshot;
|
||||
|
|
@ -49,22 +50,14 @@ macro_rules! log_cmd {
|
|||
};
|
||||
}
|
||||
|
||||
fn session_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let t = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
format!("{:06x}", t & 0xFFFFFF)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Config {
|
||||
relay_url: String,
|
||||
api_key: String,
|
||||
label: Option<String>,
|
||||
session_id: Option<String>, // persistent UUID
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -129,7 +122,7 @@ fn prompt_config() -> Config {
|
|||
}
|
||||
};
|
||||
|
||||
Config { relay_url, api_key, label }
|
||||
Config { relay_url, api_key, label, session_id: None }
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -158,6 +151,20 @@ 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() {
|
||||
log_err!("Failed to save session_id: {e}");
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let config = Arc::new(config);
|
||||
let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
|
||||
|
||||
|
|
@ -183,14 +190,13 @@ async fn main() {
|
|||
|
||||
match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
let sid = session_id();
|
||||
let label = config.label.clone().unwrap_or_else(|| hostname());
|
||||
log_ok!(
|
||||
"Connected {} {} {} Session {}",
|
||||
"·".dimmed(),
|
||||
label.bold(),
|
||||
"·".dimmed(),
|
||||
sid.dimmed()
|
||||
sid.to_string().dimmed()
|
||||
);
|
||||
println!();
|
||||
backoff = Duration::from_secs(1);
|
||||
|
|
@ -499,6 +505,51 @@ async fn handle_message(
|
|||
}
|
||||
}
|
||||
|
||||
ServerMessage::RunRequest { request_id, program, args } => {
|
||||
log_cmd!("run › {}", program);
|
||||
use std::process::Command as StdCommand;
|
||||
match StdCommand::new(&program).args(&args).spawn() {
|
||||
Ok(_) => {
|
||||
log_ok!("Started {}", program);
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
Err(e) => {
|
||||
log_err!("run failed: {e}");
|
||||
ClientMessage::Error { request_id, message: format!("Failed to start '{}': {e}", program) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ClipboardGetRequest { request_id } => {
|
||||
log_cmd!("clipboard-get");
|
||||
let out = tokio::process::Command::new("powershell.exe")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard"])
|
||||
.output().await;
|
||||
match out {
|
||||
Ok(o) => {
|
||||
let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
log_ok!("Got {} chars", text.len());
|
||||
ClientMessage::ClipboardGetResponse { request_id, text }
|
||||
}
|
||||
Err(e) => ClientMessage::Error { request_id, message: format!("Clipboard get failed: {e}") }
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ClipboardSetRequest { request_id, text } => {
|
||||
log_cmd!("clipboard-set › {} chars", text.len());
|
||||
let cmd = format!("Set-Clipboard -Value '{}'", text.replace('\'', "''"));
|
||||
let out = tokio::process::Command::new("powershell.exe")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &cmd])
|
||||
.output().await;
|
||||
match out {
|
||||
Ok(_) => {
|
||||
log_ok!("Set clipboard");
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
Err(e) => ClientMessage::Error { request_id, message: format!("Clipboard set failed: {e}") }
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::Ack { request_id } => {
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,16 @@ pub enum ServerMessage {
|
|||
request_id: Uuid,
|
||||
path: String,
|
||||
},
|
||||
/// Launch a program on the client (fire-and-forget)
|
||||
RunRequest {
|
||||
request_id: Uuid,
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// Get the contents of the client's clipboard
|
||||
ClipboardGetRequest { request_id: Uuid },
|
||||
/// Set the contents of the client's clipboard
|
||||
ClipboardSetRequest { request_id: Uuid, text: String },
|
||||
}
|
||||
|
||||
/// Messages sent from the client to the relay server
|
||||
|
|
@ -106,6 +116,8 @@ pub enum ClientMessage {
|
|||
content_base64: String,
|
||||
size: u64,
|
||||
},
|
||||
/// Response to a clipboard-get request
|
||||
ClipboardGetResponse { request_id: Uuid, text: String },
|
||||
}
|
||||
|
||||
/// Mouse button variants
|
||||
|
|
|
|||
|
|
@ -404,6 +404,81 @@ pub async fn download_file(
|
|||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/run
|
||||
#[derive(Deserialize)]
|
||||
pub struct RunBody {
|
||||
pub program: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn run_program(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RunBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "run", |rid| ServerMessage::RunRequest {
|
||||
request_id: rid,
|
||||
program: body.program.clone(),
|
||||
args: body.args.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /sessions/:id/clipboard
|
||||
pub async fn clipboard_get(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "clipboard_get", |rid| {
|
||||
ServerMessage::ClipboardGetRequest { request_id: rid }
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(ClientMessage::ClipboardGetResponse { text, .. }) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "text": text })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(ClientMessage::Error { message, .. }) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": message })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(_) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(serde_json::json!({ "error": "Unexpected response from client" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/clipboard
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClipboardSetBody {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn clipboard_set(
|
||||
Path(session_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ClipboardSetBody>,
|
||||
) -> impl IntoResponse {
|
||||
match dispatch(&state, &session_id, "clipboard_set", |rid| {
|
||||
ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/label
|
||||
#[derive(Deserialize)]
|
||||
pub struct LabelBody {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
.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))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), require_api_key));
|
||||
|
||||
let app = Router::new()
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &App
|
|||
| ClientMessage::ListWindowsResponse { request_id, .. }
|
||||
| ClientMessage::VersionResponse { request_id, .. }
|
||||
| ClientMessage::DownloadResponse { request_id, .. }
|
||||
| ClientMessage::ClipboardGetResponse { request_id, .. }
|
||||
| ClientMessage::Ack { request_id }
|
||||
| ClientMessage::Error { request_id, .. } => {
|
||||
let rid = *request_id;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue