diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f88b336..31cbc95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index a1e0f03..df185b3 100644 --- a/README.md +++ b/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//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 # capture screenshot → /tmp/helios-remote-screenshot.png +python remote.py exec # run shell command +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 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. diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 498ff00..90210e3 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -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"] } diff --git a/crates/client/build.rs b/crates/client/build.rs index 6754ee2..0bbc458 100644 --- a/crates/client/build.rs +++ b/crates/client/build.rs @@ -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}")); + } } diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 3b5adee..19b435b 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -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 } } diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs index ffb4a22..70120bc 100644 --- a/crates/common/src/protocol.rs +++ b/crates/common/src/protocol.rs @@ -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 diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs index 2df3832..a6269d0 100644 --- a/crates/server/src/api.rs +++ b/crates/server/src/api.rs @@ -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 { diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 3024d4f..1f8d35f 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -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() diff --git a/crates/server/src/ws_handler.rs b/crates/server/src/ws_handler.rs index c44ac8d..4fdecb8 100644 --- a/crates/server/src/ws_handler.rs +++ b/crates/server/src/ws_handler.rs @@ -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;