diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index ac4b509..a0a968b 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -27,4 +27,5 @@ windows = { version = "0.54", features = [ "Win32_Graphics_Gdi", "Win32_UI_Input_KeyboardAndMouse", "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index a2cb802..468d097 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -14,6 +14,7 @@ use helios_common::{ClientMessage, ServerMessage}; mod shell; mod screenshot; mod input; +mod windows_mgmt; #[derive(Debug, Serialize, Deserialize)] struct Config { @@ -266,6 +267,50 @@ async fn handle_message( } } + ServerMessage::ListWindowsRequest { request_id } => { + info!("ListWindows"); + match windows_mgmt::list_windows() { + Ok(windows) => ClientMessage::ListWindowsResponse { request_id, windows }, + Err(e) => { + error!("ListWindows failed: {e}"); + ClientMessage::Error { request_id, message: e } + } + } + } + + ServerMessage::MinimizeAllRequest { request_id } => { + info!("MinimizeAll"); + match windows_mgmt::minimize_all() { + Ok(()) => ClientMessage::Ack { request_id }, + Err(e) => { + error!("MinimizeAll failed: {e}"); + ClientMessage::Error { request_id, message: e } + } + } + } + + ServerMessage::FocusWindowRequest { request_id, window_id } => { + info!("FocusWindow: {window_id}"); + match windows_mgmt::focus_window(window_id) { + Ok(()) => ClientMessage::Ack { request_id }, + Err(e) => { + error!("FocusWindow failed: {e}"); + ClientMessage::Error { request_id, message: e } + } + } + } + + ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => { + info!("MaximizeAndFocus: {window_id}"); + match windows_mgmt::maximize_and_focus(window_id) { + Ok(()) => ClientMessage::Ack { request_id }, + Err(e) => { + error!("MaximizeAndFocus failed: {e}"); + ClientMessage::Error { request_id, message: e } + } + } + } + ServerMessage::Ack { request_id } => { info!("Server ack for {request_id}"); // Nothing to do - server acked something we sent diff --git a/crates/client/src/windows_mgmt.rs b/crates/client/src/windows_mgmt.rs new file mode 100644 index 0000000..465cb96 --- /dev/null +++ b/crates/client/src/windows_mgmt.rs @@ -0,0 +1,130 @@ +use helios_common::protocol::WindowInfo; + +// ── Windows implementation ────────────────────────────────────────────────── + +#[cfg(windows)] +mod win_impl { + use super::*; + use std::sync::Mutex; + use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; + use windows::Win32::UI::WindowsAndMessaging::{ + BringWindowToTop, EnumWindows, GetWindowTextW, IsWindowVisible, SetForegroundWindow, + ShowWindow, SW_MAXIMIZE, SW_MINIMIZE, SHOW_WINDOW_CMD, + }; + + // Collect HWNDs via EnumWindows callback + unsafe extern "system" fn enum_callback(hwnd: HWND, lparam: LPARAM) -> BOOL { + let list = &mut *(lparam.0 as *mut Vec); + list.push(hwnd); + BOOL(1) + } + + fn get_all_hwnds() -> Vec { + let mut list: Vec = Vec::new(); + unsafe { + let _ = EnumWindows( + Some(enum_callback), + LPARAM(&mut list as *mut Vec as isize), + ); + } + list + } + + fn hwnd_title(hwnd: HWND) -> String { + let mut buf = [0u16; 512]; + let len = unsafe { GetWindowTextW(hwnd, &mut buf) }; + String::from_utf16_lossy(&buf[..len as usize]) + } + + pub fn list_windows() -> Result, 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); + if title.is_empty() { + continue; + } + windows.push(WindowInfo { + id: hwnd.0 as u64, + title, + visible, + }); + } + Ok(windows) + } + + pub fn minimize_all() -> Result<(), String> { + let hwnds = get_all_hwnds(); + for hwnd in hwnds { + let visible = unsafe { IsWindowVisible(hwnd).as_bool() }; + let title = hwnd_title(hwnd); + if visible && !title.is_empty() { + unsafe { + let _ = ShowWindow(hwnd, SW_MINIMIZE); + } + } + } + Ok(()) + } + + pub fn focus_window(window_id: u64) -> Result<(), String> { + let hwnd = HWND(window_id as isize); + unsafe { + BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?; + SetForegroundWindow(hwnd); + } + Ok(()) + } + + pub fn maximize_and_focus(window_id: u64) -> Result<(), String> { + let hwnd = HWND(window_id as isize); + unsafe { + ShowWindow(hwnd, SW_MAXIMIZE); + BringWindowToTop(hwnd).map_err(|e| format!("BringWindowToTop failed: {e}"))?; + SetForegroundWindow(hwnd); + } + Ok(()) + } +} + +// ── Non-Windows stubs ─────────────────────────────────────────────────────── + +#[cfg(not(windows))] +mod win_impl { + use super::*; + + pub fn list_windows() -> Result, String> { + Err("Window management is only supported on Windows".to_string()) + } + + pub fn minimize_all() -> Result<(), String> { + Err("Window management is only supported on Windows".to_string()) + } + + pub fn focus_window(_window_id: u64) -> Result<(), String> { + Err("Window management is only supported on Windows".to_string()) + } + + pub fn maximize_and_focus(_window_id: u64) -> Result<(), String> { + Err("Window management is only supported on Windows".to_string()) + } +} + +// ── Public API ────────────────────────────────────────────────────────────── + +pub fn list_windows() -> Result, String> { + win_impl::list_windows() +} + +pub fn minimize_all() -> Result<(), String> { + win_impl::minimize_all() +} + +pub fn focus_window(window_id: u64) -> Result<(), String> { + win_impl::focus_window(window_id) +} + +pub fn maximize_and_focus(window_id: u64) -> Result<(), String> { + win_impl::maximize_and_focus(window_id) +} diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs index 98db984..3f9a8f9 100644 --- a/crates/common/src/protocol.rs +++ b/crates/common/src/protocol.rs @@ -1,6 +1,14 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Information about a single window on the client machine +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WindowInfo { + pub id: u64, + pub title: String, + pub visible: bool, +} + /// Messages sent from the relay server to a connected client #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -31,6 +39,14 @@ pub enum ServerMessage { request_id: Option, message: String, }, + /// List all visible windows on the client + ListWindowsRequest { request_id: Uuid }, + /// Minimize all windows (like Win+D) + MinimizeAllRequest { request_id: Uuid }, + /// Bring a window to the foreground + FocusWindowRequest { request_id: Uuid, window_id: u64 }, + /// Maximize a window and bring it to the foreground + MaximizeAndFocusRequest { request_id: Uuid, window_id: u64 }, } /// Messages sent from the client to the relay server @@ -53,13 +69,18 @@ pub enum ClientMessage { stderr: String, exit_code: i32, }, - /// Generic acknowledgement for click/type + /// Generic acknowledgement for click/type/minimize-all/focus/maximize Ack { request_id: Uuid }, /// Client error response Error { request_id: Uuid, message: String, }, + /// Response to a list-windows request + ListWindowsResponse { + request_id: Uuid, + windows: Vec, + }, } /// Mouse button variants diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs index e590500..c670abc 100644 --- a/crates/server/src/api.rs +++ b/crates/server/src/api.rs @@ -233,6 +233,80 @@ pub async fn request_type( } } +/// GET /sessions/:id/windows +pub async fn list_windows( + Path(session_id): Path, + State(state): State, +) -> impl IntoResponse { + match dispatch(&state, &session_id, "list_windows", |rid| { + ServerMessage::ListWindowsRequest { request_id: rid } + }) + .await + { + Ok(ClientMessage::ListWindowsResponse { windows, .. }) => ( + StatusCode::OK, + Json(serde_json::json!({ "windows": windows })), + ) + .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/windows/minimize-all +pub async fn minimize_all( + Path(session_id): Path, + State(state): State, +) -> impl IntoResponse { + match dispatch(&state, &session_id, "minimize_all", |rid| { + ServerMessage::MinimizeAllRequest { request_id: rid } + }) + .await + { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), + Err(e) => e.into_response(), + } +} + +/// POST /sessions/:id/windows/:window_id/focus +pub async fn focus_window( + Path((session_id, window_id)): Path<(String, u64)>, + State(state): State, +) -> impl IntoResponse { + match dispatch(&state, &session_id, "focus_window", |rid| { + ServerMessage::FocusWindowRequest { request_id: rid, window_id } + }) + .await + { + Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(), + Err(e) => e.into_response(), + } +} + +/// POST /sessions/:id/windows/:window_id/maximize +pub async fn maximize_and_focus( + Path((session_id, window_id)): Path<(String, u64)>, + State(state): State, +) -> impl IntoResponse { + match dispatch(&state, &session_id, "maximize_and_focus", |rid| { + ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id } + }) + .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 d9a997d..9e88a6c 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -48,6 +48,10 @@ async fn main() -> anyhow::Result<()> { .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/windows", get(api::list_windows)) + .route("/sessions/:id/windows/minimize-all", post(api::minimize_all)) + .route("/sessions/:id/windows/:window_id/focus", post(api::focus_window)) + .route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus)) .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 cdbeeba..a5ad66e 100644 --- a/crates/server/src/ws_handler.rs +++ b/crates/server/src/ws_handler.rs @@ -88,6 +88,7 @@ async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &App } ClientMessage::ScreenshotResponse { request_id, .. } | ClientMessage::ExecResponse { request_id, .. } + | ClientMessage::ListWindowsResponse { request_id, .. } | ClientMessage::Ack { request_id } | ClientMessage::Error { request_id, .. } => { let rid = *request_id;