feat: add window management (list, minimize-all, focus, maximize)
This commit is contained in:
parent
c9643c8543
commit
9f06d84f28
7 changed files with 277 additions and 1 deletions
|
|
@ -27,4 +27,5 @@ windows = { version = "0.54", features = [
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_UI_Input_KeyboardAndMouse",
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
] }
|
] }
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use helios_common::{ClientMessage, ServerMessage};
|
||||||
mod shell;
|
mod shell;
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
mod input;
|
mod input;
|
||||||
|
mod windows_mgmt;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct Config {
|
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 } => {
|
ServerMessage::Ack { request_id } => {
|
||||||
info!("Server ack for {request_id}");
|
info!("Server ack for {request_id}");
|
||||||
// Nothing to do - server acked something we sent
|
// Nothing to do - server acked something we sent
|
||||||
|
|
|
||||||
130
crates/client/src/windows_mgmt.rs
Normal file
130
crates/client/src/windows_mgmt.rs
Normal file
|
|
@ -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<HWND>);
|
||||||
|
list.push(hwnd);
|
||||||
|
BOOL(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_all_hwnds() -> Vec<HWND> {
|
||||||
|
let mut list: Vec<HWND> = Vec::new();
|
||||||
|
unsafe {
|
||||||
|
let _ = EnumWindows(
|
||||||
|
Some(enum_callback),
|
||||||
|
LPARAM(&mut list as *mut Vec<HWND> 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<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);
|
||||||
|
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<Vec<WindowInfo>, 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<Vec<WindowInfo>, 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)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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
|
/// Messages sent from the relay server to a connected client
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
|
@ -31,6 +39,14 @@ pub enum ServerMessage {
|
||||||
request_id: Option<Uuid>,
|
request_id: Option<Uuid>,
|
||||||
message: String,
|
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
|
/// Messages sent from the client to the relay server
|
||||||
|
|
@ -53,13 +69,18 @@ pub enum ClientMessage {
|
||||||
stderr: String,
|
stderr: String,
|
||||||
exit_code: i32,
|
exit_code: i32,
|
||||||
},
|
},
|
||||||
/// Generic acknowledgement for click/type
|
/// Generic acknowledgement for click/type/minimize-all/focus/maximize
|
||||||
Ack { request_id: Uuid },
|
Ack { request_id: Uuid },
|
||||||
/// Client error response
|
/// Client error response
|
||||||
Error {
|
Error {
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
/// Response to a list-windows request
|
||||||
|
ListWindowsResponse {
|
||||||
|
request_id: Uuid,
|
||||||
|
windows: Vec<WindowInfo>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mouse button variants
|
/// Mouse button variants
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,80 @@ pub async fn request_type(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /sessions/:id/windows
|
||||||
|
pub async fn list_windows(
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> 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<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
) -> 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
|
/// POST /sessions/:id/label
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LabelBody {
|
pub struct LabelBody {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.route("/sessions/:id/click", post(api::request_click))
|
.route("/sessions/:id/click", post(api::request_click))
|
||||||
.route("/sessions/:id/type", post(api::request_type))
|
.route("/sessions/:id/type", post(api::request_type))
|
||||||
.route("/sessions/:id/label", post(api::set_label))
|
.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));
|
.layer(middleware::from_fn_with_state(state.clone(), require_api_key));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &App
|
||||||
}
|
}
|
||||||
ClientMessage::ScreenshotResponse { request_id, .. }
|
ClientMessage::ScreenshotResponse { request_id, .. }
|
||||||
| ClientMessage::ExecResponse { request_id, .. }
|
| ClientMessage::ExecResponse { request_id, .. }
|
||||||
|
| ClientMessage::ListWindowsResponse { request_id, .. }
|
||||||
| ClientMessage::Ack { request_id }
|
| ClientMessage::Ack { request_id }
|
||||||
| ClientMessage::Error { request_id, .. } => {
|
| ClientMessage::Error { request_id, .. } => {
|
||||||
let rid = *request_id;
|
let rid = *request_id;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue