From 71a9018e4babc4ef8d420fb329c76b41f59a6089 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Fri, 6 Nov 2026 12:14:23 -0800 Subject: [PATCH] refactor(e2e-agent): split the protocol from the verbs of a platform The agent of the Windows guest speaks a line protocol (one JSON request, one JSON answer, raw bytes after the line for put_file and get_file) that has nothing to do with Windows. Move it into protocol.rs, with a trait for the verbs a platform adds, so that another platform answers the same protocol instead of copying it, and the host has one client. The Windows verbs (os_windows, displays, activate, minimize, close) and the code that reads them from the session are moved to windows.rs, not changed; the agent still serves each connection on a thread of its own. What the protocol gains for every platform: an environment and an output file for a spawned program, a `status` of one, and a `send` to its standard input, which the Windows agent does not use yet. The port is one for every platform, as the Windows one already was. --- crates/komorebi-e2e-agent/Cargo.toml | 2 + crates/komorebi-e2e-agent/src/main.rs | 318 ++-------------------- crates/komorebi-e2e-agent/src/protocol.rs | 208 ++++++++++++++ crates/komorebi-e2e-agent/src/windows.rs | 214 +++++++++++++++ 4 files changed, 441 insertions(+), 301 deletions(-) create mode 100644 crates/komorebi-e2e-agent/src/protocol.rs create mode 100644 crates/komorebi-e2e-agent/src/windows.rs diff --git a/crates/komorebi-e2e-agent/Cargo.toml b/crates/komorebi-e2e-agent/Cargo.toml index 8a36470a..e9a78ae0 100644 --- a/crates/komorebi-e2e-agent/Cargo.toml +++ b/crates/komorebi-e2e-agent/Cargo.toml @@ -8,4 +8,6 @@ publish = false [dependencies] serde = { workspace = true } serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] windows = { workspace = true } diff --git a/crates/komorebi-e2e-agent/src/main.rs b/crates/komorebi-e2e-agent/src/main.rs index 2a4e9fdd..58f8565b 100644 --- a/crates/komorebi-e2e-agent/src/main.rs +++ b/crates/komorebi-e2e-agent/src/main.rs @@ -1,307 +1,23 @@ -//! The guest half of the e2e suite on Windows. +//! The guest half of the e2e suite. //! -//! It must run inside the interactive session (a Run entry of the autologon -//! user), because session 0 has no usable desktop. The host talks to it over -//! a TCP port that QEMU forwards from the host's loopback. One request is one -//! line of JSON, `{"id": 1, "verb": "...", ...}`, answered by one line, -//! `{"id": 1, "ok": ...}` or `{"id": 1, "error": "..."}`. -//! -//! `put_file` and `get_file` move bytes after the JSON line, as the `size` -//! raw bytes that the request or the answer announces, so that a daemon of -//! tens of megabytes is not wrapped in JSON. -//! -//! The agent injects no input: keys, pointer and screenshots come from the -//! host (QMP), which also works at the lock screen. +//! It runs inside the interactive session of the guest, and the host talks to it over TCP: the +//! protocol is the same on every platform (`protocol.rs`), and what a platform adds to it is the +//! windows and the displays of its own desktop (`windows.rs`, `macos.rs`). -#![cfg(windows)] -// no console window: it would be a window for the daemon to tile -#![windows_subsystem = "windows"] +// no console window on Windows: it would be a window for the daemon to tile +#![cfg_attr(windows, windows_subsystem = "windows")] -use std::io::BufRead; -use std::io::BufReader; -use std::io::Read; -use std::io::Write; -use std::net::TcpListener; -use std::net::TcpStream; -use std::os::windows::process::CommandExt; -use std::process::Command; - -use serde::Deserialize; -use serde_json::Value; -use serde_json::json; -use windows::Win32::Foundation::HWND; -use windows::Win32::Foundation::LPARAM; -use windows::Win32::Foundation::RECT; -use windows::Win32::Foundation::WPARAM; -use windows::Win32::Graphics::Dwm::DWMWA_CLOAKED; -use windows::Win32::Graphics::Dwm::DWMWA_EXTENDED_FRAME_BOUNDS; -use windows::Win32::Graphics::Dwm::DwmGetWindowAttribute; -use windows::Win32::Graphics::Gdi::EnumDisplayMonitors; -use windows::Win32::Graphics::Gdi::GetMonitorInfoW; -use windows::Win32::Graphics::Gdi::HDC; -use windows::Win32::Graphics::Gdi::HMONITOR; -use windows::Win32::Graphics::Gdi::MONITORINFO; -use windows::Win32::UI::WindowsAndMessaging::EnumWindows; -use windows::Win32::UI::WindowsAndMessaging::GetClassNameW; -use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; -use windows::Win32::UI::WindowsAndMessaging::GetWindowTextW; -use windows::Win32::UI::WindowsAndMessaging::IsIconic; -use windows::Win32::UI::WindowsAndMessaging::IsWindowVisible; -use windows::Win32::UI::WindowsAndMessaging::PostMessageW; -use windows::Win32::UI::WindowsAndMessaging::SW_MINIMIZE; -use windows::Win32::UI::WindowsAndMessaging::SW_RESTORE; -use windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow; -use windows::Win32::UI::WindowsAndMessaging::ShowWindow; -use windows::Win32::UI::WindowsAndMessaging::WM_CLOSE; -use windows::core::BOOL; - -const DEFAULT_PORT: u16 = 7878; - -/// A windowless program has nowhere to print a panic, so it goes here. -const LOG: &str = "C:\\e2e\\agent.log"; - -fn log(message: &str) { - use std::io::Write as _; - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(LOG) - { - let _ = writeln!(file, "{message}"); - } -} - -/// `CREATE_NO_WINDOW`: a child must not open a console of its own either. -const NO_WINDOW: u32 = 0x0800_0000; - -#[derive(Deserialize)] -struct Request { - id: Value, - verb: String, - #[serde(default)] - args: Vec, - #[serde(default)] - path: String, - #[serde(default)] - size: u64, - #[serde(default)] - hwnd: isize, -} - -fn rect_json(r: &RECT) -> Value { - // the shape komorebi itself prints: right and bottom are width and height - json!({ - "left": r.left, "top": r.top, - "right": r.right - r.left, "bottom": r.bottom - r.top, - }) -} - -fn hwnd_of(raw: isize) -> HWND { - HWND(raw as *mut _) -} - -/// One top-level window as the operating system describes it. -fn describe(hwnd: HWND) -> Option { - let mut title = [0u16; 512]; - // SAFETY: the buffer is valid for the length it is given. - let n = unsafe { GetWindowTextW(hwnd, &mut title) }; - if n <= 0 { - return None; - } - let mut class = [0u16; 256]; - // SAFETY: as above. - let c = unsafe { GetClassNameW(hwnd, &mut class) }; - - let mut frame = RECT::default(); - // SAFETY: `frame` is a RECT and its size is passed. - let _ = unsafe { - DwmGetWindowAttribute( - hwnd, - DWMWA_EXTENDED_FRAME_BOUNDS, - (&mut frame as *mut RECT).cast(), - std::mem::size_of::() as u32, - ) - }; - let mut cloaked = 0u32; - // SAFETY: `cloaked` is a u32 and its size is passed. - let _ = unsafe { - DwmGetWindowAttribute( - hwnd, - DWMWA_CLOAKED, - (&mut cloaked as *mut u32).cast(), - std::mem::size_of::() as u32, - ) - }; - - // SAFETY: plain queries on a window handle. - let (visible, minimized, focused) = unsafe { - ( - IsWindowVisible(hwnd).as_bool(), - IsIconic(hwnd).as_bool(), - GetForegroundWindow() == hwnd, - ) - }; - Some(json!({ - "hwnd": hwnd.0 as isize, - "title": String::from_utf16_lossy(&title[..n as usize]), - "class": String::from_utf16_lossy(&class[..c.max(0) as usize]), - "frame": rect_json(&frame), - "visible": visible, - "minimized": minimized, - "cloaked": cloaked != 0, - "focused": focused, - })) -} - -fn os_windows() -> Vec { - unsafe extern "system" fn each(hwnd: HWND, out: LPARAM) -> BOOL { - // SAFETY: `out` is the `Vec` passed below, alive for the whole call. - let out = unsafe { &mut *(out.0 as *mut Vec) }; - out.extend(describe(hwnd)); - BOOL(1) - } - let mut out = Vec::new(); - // SAFETY: the callback only touches the `Vec` passed as the parameter. - let _ = unsafe { EnumWindows(Some(each), LPARAM(&mut out as *mut _ as isize)) }; - out -} - -fn displays() -> Vec { - unsafe extern "system" fn each(monitor: HMONITOR, _: HDC, _: *mut RECT, out: LPARAM) -> BOOL { - // SAFETY: `out` is the `Vec` passed below, alive for the whole call. - let out = unsafe { &mut *(out.0 as *mut Vec) }; - let mut info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - // SAFETY: `info` has its size set, as the call requires. - if unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() { - out.push(json!({ - "monitor": rect_json(&info.rcMonitor), - "work_area": rect_json(&info.rcWork), - "primary": info.dwFlags & 1 != 0, - })); - } - BOOL(1) - } - let mut out = Vec::new(); - // SAFETY: the callback only touches the `Vec` passed as the parameter. - let _ = - unsafe { EnumDisplayMonitors(None, None, Some(each), LPARAM(&mut out as *mut _ as isize)) }; - out -} - -fn answer(stream: &mut TcpStream, id: &Value, result: Result) { - let line = match result { - Ok(ok) => json!({"id": id, "ok": ok}), - Err(error) => json!({"id": id, "error": error}), - }; - let _ = writeln!(stream, "{line}"); -} - -fn exec(args: &[String]) -> Result { - let (program, rest) = args.split_first().ok_or("exec wants a program")?; - let out = Command::new(program) - .args(rest) - .creation_flags(NO_WINDOW) - .output() - .map_err(|e| format!("{program}: {e}"))?; - Ok(json!({ - "status": out.status.code(), - "stdout": String::from_utf8_lossy(&out.stdout), - "stderr": String::from_utf8_lossy(&out.stderr), - })) -} - -/// Starts a program that keeps running, in this session, and returns at once. -fn spawn(args: &[String]) -> Result { - let (program, rest) = args.split_first().ok_or("spawn wants a program")?; - let child = Command::new(program) - .args(rest) - .creation_flags(NO_WINDOW) - .spawn() - .map_err(|e| format!("{program}: {e}"))?; - Ok(json!({"pid": child.id()})) -} - -fn serve(stream: TcpStream) -> std::io::Result<()> { - let mut writer = stream.try_clone()?; - let mut reader = BufReader::new(stream); - let mut line = String::new(); - loop { - line.clear(); - if reader.read_line(&mut line)? == 0 { - return Ok(()); - } - let req: Request = match serde_json::from_str(&line) { - Ok(req) => req, - Err(e) => { - answer(&mut writer, &Value::Null, Err(format!("bad request: {e}"))); - continue; - } - }; - let hwnd = hwnd_of(req.hwnd); - let result = match req.verb.as_str() { - "ping" => Ok(json!("pong")), - "exec" => exec(&req.args), - "spawn" => spawn(&req.args), - "os_windows" => Ok(Value::Array(os_windows())), - "displays" => Ok(Value::Array(displays())), - "activate" => { - // SAFETY: plain calls on a window handle given by the host. - unsafe { - let _ = ShowWindow(hwnd, SW_RESTORE); - Ok(json!(SetForegroundWindow(hwnd).as_bool())) - } - } - "minimize" => { - // SAFETY: as above. - Ok(json!(unsafe { ShowWindow(hwnd, SW_MINIMIZE) }.as_bool())) - } - "close" => { - // SAFETY: as above. - unsafe { PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)) } - .map(|()| json!(true)) - .map_err(|e| e.to_string()) - } - "put_file" => { - let mut bytes = vec![0u8; req.size as usize]; - reader.read_exact(&mut bytes)?; - std::fs::write(&req.path, bytes) - .map(|()| json!(req.size)) - .map_err(|e| format!("{}: {e}", req.path)) - } - "get_file" => match std::fs::read(&req.path) { - Ok(bytes) => { - answer(&mut writer, &req.id, Ok(json!({"size": bytes.len()}))); - writer.write_all(&bytes)?; - continue; - } - Err(e) => Err(format!("{}: {e}", req.path)), - }, - other => Err(format!("unknown verb {other}")), - }; - answer(&mut writer, &req.id, result); - } -} +mod protocol; +#[cfg(windows)] +mod windows; +#[cfg(windows)] fn main() -> std::io::Result<()> { - std::panic::set_hook(Box::new(|info| log(&format!("panic: {info}")))); - let port = std::env::var("KOMOREBI_E2E_AGENT_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(DEFAULT_PORT); - // QEMU's user-mode network forwards the host port to the guest's own - // address, so loopback would not see it. The guest is on a private - // network of its own and has its firewall off (see `setup.ps1`). - let listener = TcpListener::bind(("0.0.0.0", port))?; - for stream in listener.incoming() { - let stream = stream?; - std::thread::spawn(move || { - if let Err(e) = serve(stream) { - log(&format!("connection ended: {e}")); - } - }); - } - Ok(()) + windows::run() +} + +#[cfg(not(windows))] +fn main() { + eprintln!("komorebi-e2e-agent runs on Windows only"); + std::process::exit(1); } diff --git a/crates/komorebi-e2e-agent/src/protocol.rs b/crates/komorebi-e2e-agent/src/protocol.rs new file mode 100644 index 00000000..327862cc --- /dev/null +++ b/crates/komorebi-e2e-agent/src/protocol.rs @@ -0,0 +1,208 @@ +//! What the agent says to the host, on every platform: one request is one line of JSON, +//! `{"id": 1, "verb": "...", ...}`, answered by one line, `{"id": 1, "ok": ...}` or +//! `{"id": 1, "error": "..."}`. `put_file` and `get_file` move bytes after the line, as the `size` +//! raw bytes that the request or the answer announces, so that a daemon of tens of megabytes is +//! not wrapped in JSON. +//! +//! The verbs every platform has are here: `ping`, `exec`, `spawn`, `status`, `send`, `put_file` +//! and `get_file`. A platform adds its own (the windows of its desktop, its displays) by +//! implementing [`Verbs`]. +//! +//! A frame is `{left, top, right, bottom}` where `right` and `bottom` are the width and the +//! height, the shape komorebi itself prints. + +use std::collections::HashMap; +use std::io::BufRead; +use std::io::BufReader; +use std::io::Read; +use std::io::Write; +use std::net::TcpStream; +use std::process::Child; +use std::process::Command; +use std::sync::Mutex; + +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; + +/// The port the agent listens on, on every platform. +pub const DEFAULT_PORT: u16 = 7878; + +/// The programs `spawn` started and that have not been looked at since they ended: the agent keeps +/// them so that `status` can say how one ended, and `send` can write to its standard input. +static CHILDREN: Mutex>> = Mutex::new(None); + +fn children(use_them: impl FnOnce(&mut HashMap) -> T) -> T { + let mut guard = CHILDREN.lock().unwrap(); + use_them(guard.get_or_insert_with(HashMap::new)) +} + +/// A request. A verb reads the fields it needs; the others are left at their defaults. +#[derive(Deserialize)] +pub struct Request { + pub id: Value, + pub verb: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub path: String, + #[serde(default)] + pub size: u64, + /// The environment a program is started with, on top of the agent's own. + #[serde(default)] + pub env: HashMap, + /// A file that gets what a spawned program prints, standard output and error. + #[serde(default)] + pub out: Option, + /// A process id of a program `spawn` started. + #[serde(default)] + pub pid: u32, + /// A line for the standard input of that program. + #[serde(default)] + pub line: String, + /// A window handle, on a platform that has them (Windows). + #[serde(default)] + pub hwnd: isize, +} + +/// Why a command is being prepared: it runs to its end, or it keeps running. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Exec, + Spawn, +} + +/// What a platform adds to the protocol. +pub trait Verbs { + /// Answers a verb of this platform, or `None` if it is not one. + fn handle(&mut self, request: &Request) -> Option>; + + /// Settings of the platform for a program the agent starts (no console window on Windows, a + /// standard input that stays open on macOS). + fn prepare(&self, _command: &mut Command, _mode: Mode) {} +} + +pub fn frame_json(left: i64, top: i64, width: i64, height: i64) -> Value { + json!({"left": left, "top": top, "right": width, "bottom": height}) +} + +fn answer(stream: &mut TcpStream, id: &Value, result: Result) { + let line = match result { + Ok(ok) => json!({"id": id, "ok": ok}), + Err(error) => json!({"id": id, "error": error}), + }; + let _ = writeln!(stream, "{line}"); +} + +fn exec(request: &Request, verbs: &dyn Verbs) -> Result { + let (program, rest) = request.args.split_first().ok_or("exec wants a program")?; + let mut command = Command::new(program); + command.args(rest).envs(&request.env); + verbs.prepare(&mut command, Mode::Exec); + let out = command.output().map_err(|e| format!("{program}: {e}"))?; + Ok(json!({ + "status": out.status.code(), + "stdout": String::from_utf8_lossy(&out.stdout), + "stderr": String::from_utf8_lossy(&out.stderr), + })) +} + +/// Starts a program that keeps running, in this session, and returns at once. +fn spawn(request: &Request, verbs: &dyn Verbs) -> Result { + let (program, rest) = request.args.split_first().ok_or("spawn wants a program")?; + let mut command = Command::new(program); + command.args(rest).envs(&request.env); + verbs.prepare(&mut command, Mode::Spawn); + if let Some(path) = &request.out { + let file = std::fs::File::create(path).map_err(|e| format!("{path}: {e}"))?; + let again = file.try_clone().map_err(|e| format!("{path}: {e}"))?; + command.stdout(file).stderr(again); + } + let child = command.spawn().map_err(|e| format!("{program}: {e}"))?; + let pid = child.id(); + children(|children| children.insert(pid, child)); + Ok(json!({"pid": pid})) +} + +fn status(pid: u32) -> Result { + children(|children| { + let child = children + .get_mut(&pid) + .ok_or_else(|| format!("{pid} was not started by this agent"))?; + match child.try_wait() { + Ok(Some(status)) => Ok(json!({"running": false, "code": status.code()})), + Ok(None) => Ok(json!({"running": true})), + Err(e) => Err(e.to_string()), + } + }) +} + +/// Writes a line to the standard input of a program `spawn` started. +fn send(pid: u32, line: &str) -> Result { + children(|children| { + let input = children + .get_mut(&pid) + .ok_or_else(|| format!("{pid} was not started by this agent"))? + .stdin + .as_mut() + .ok_or_else(|| format!("{pid} has no standard input"))?; + writeln!(input, "{line}") + .and_then(|()| input.flush()) + .map(|()| json!(true)) + .map_err(|e| format!("{pid}: {e}")) + }) +} + +/// Serves one connection until the host closes it. +pub fn serve(stream: TcpStream, verbs: &mut dyn Verbs) -> std::io::Result<()> { + let mut writer = stream.try_clone()?; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + return Ok(()); + } + let request: Request = match serde_json::from_str(&line) { + Ok(request) => request, + Err(e) => { + answer(&mut writer, &Value::Null, Err(format!("bad request: {e}"))); + continue; + } + }; + let result = match request.verb.as_str() { + "ping" => Ok(json!("pong")), + "exec" => exec(&request, verbs), + "spawn" => spawn(&request, verbs), + "status" => status(request.pid), + "send" => send(request.pid, &request.line), + "put_file" => { + let mut bytes = vec![0u8; request.size as usize]; + reader.read_exact(&mut bytes)?; + std::fs::write(&request.path, bytes) + .map(|()| json!(request.size)) + .map_err(|e| format!("{}: {e}", request.path)) + } + "get_file" => match std::fs::read(&request.path) { + Ok(bytes) => { + answer(&mut writer, &request.id, Ok(json!({"size": bytes.len()}))); + writer.write_all(&bytes)?; + continue; + } + Err(e) => Err(format!("{}: {e}", request.path)), + }, + other => verbs + .handle(&request) + .unwrap_or_else(|| Err(format!("unknown verb {other}"))), + }; + answer(&mut writer, &request.id, result); + } +} + +/// The port from the environment, or the default. +pub fn port() -> u16 { + std::env::var("KOMOREBI_E2E_AGENT_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(DEFAULT_PORT) +} diff --git a/crates/komorebi-e2e-agent/src/windows.rs b/crates/komorebi-e2e-agent/src/windows.rs new file mode 100644 index 00000000..28413a23 --- /dev/null +++ b/crates/komorebi-e2e-agent/src/windows.rs @@ -0,0 +1,214 @@ +//! The verbs of the Windows agent: the windows and displays of the interactive session. +//! +//! It must run inside the interactive session (a Run entry of the autologon user), because +//! session 0 has no usable desktop. The agent injects no input: keys, pointer and screenshots +//! come from the host (QMP), which also works at the lock screen. + +use std::net::TcpListener; +use std::os::windows::process::CommandExt; +use std::process::Command; + +use serde_json::Value; +use serde_json::json; +use windows::Win32::Foundation::HWND; +use windows::Win32::Foundation::LPARAM; +use windows::Win32::Foundation::RECT; +use windows::Win32::Foundation::WPARAM; +use windows::Win32::Graphics::Dwm::DWMWA_CLOAKED; +use windows::Win32::Graphics::Dwm::DWMWA_EXTENDED_FRAME_BOUNDS; +use windows::Win32::Graphics::Dwm::DwmGetWindowAttribute; +use windows::Win32::Graphics::Gdi::EnumDisplayMonitors; +use windows::Win32::Graphics::Gdi::GetMonitorInfoW; +use windows::Win32::Graphics::Gdi::HDC; +use windows::Win32::Graphics::Gdi::HMONITOR; +use windows::Win32::Graphics::Gdi::MONITORINFO; +use windows::Win32::UI::WindowsAndMessaging::EnumWindows; +use windows::Win32::UI::WindowsAndMessaging::GetClassNameW; +use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; +use windows::Win32::UI::WindowsAndMessaging::GetWindowTextW; +use windows::Win32::UI::WindowsAndMessaging::IsIconic; +use windows::Win32::UI::WindowsAndMessaging::IsWindowVisible; +use windows::Win32::UI::WindowsAndMessaging::PostMessageW; +use windows::Win32::UI::WindowsAndMessaging::SW_MINIMIZE; +use windows::Win32::UI::WindowsAndMessaging::SW_RESTORE; +use windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow; +use windows::Win32::UI::WindowsAndMessaging::ShowWindow; +use windows::Win32::UI::WindowsAndMessaging::WM_CLOSE; +use windows::core::BOOL; + +use crate::protocol::Mode; +use crate::protocol::Request; +use crate::protocol::Verbs; +use crate::protocol::frame_json; + +/// A windowless program has nowhere to print a panic, so it goes here. +const LOG: &str = "C:\\e2e\\agent.log"; + +fn log(message: &str) { + use std::io::Write as _; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(LOG) + { + let _ = writeln!(file, "{message}"); + } +} + +/// `CREATE_NO_WINDOW`: a child must not open a console of its own either. +const NO_WINDOW: u32 = 0x0800_0000; + +fn rect_json(r: &RECT) -> Value { + frame_json( + r.left.into(), + r.top.into(), + (r.right - r.left).into(), + (r.bottom - r.top).into(), + ) +} + +fn hwnd_of(raw: isize) -> HWND { + HWND(raw as *mut _) +} + +/// One top-level window as the operating system describes it. +fn describe(hwnd: HWND) -> Option { + let mut title = [0u16; 512]; + // SAFETY: the buffer is valid for the length it is given. + let n = unsafe { GetWindowTextW(hwnd, &mut title) }; + if n <= 0 { + return None; + } + let mut class = [0u16; 256]; + // SAFETY: as above. + let c = unsafe { GetClassNameW(hwnd, &mut class) }; + + let mut frame = RECT::default(); + // SAFETY: `frame` is a RECT and its size is passed. + let _ = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS, + (&mut frame as *mut RECT).cast(), + std::mem::size_of::() as u32, + ) + }; + let mut cloaked = 0u32; + // SAFETY: `cloaked` is a u32 and its size is passed. + let _ = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_CLOAKED, + (&mut cloaked as *mut u32).cast(), + std::mem::size_of::() as u32, + ) + }; + + // SAFETY: plain queries on a window handle. + let (visible, minimized, focused) = unsafe { + ( + IsWindowVisible(hwnd).as_bool(), + IsIconic(hwnd).as_bool(), + GetForegroundWindow() == hwnd, + ) + }; + Some(json!({ + "hwnd": hwnd.0 as isize, + "title": String::from_utf16_lossy(&title[..n as usize]), + "class": String::from_utf16_lossy(&class[..c.max(0) as usize]), + "frame": rect_json(&frame), + "visible": visible, + "minimized": minimized, + "cloaked": cloaked != 0, + "focused": focused, + })) +} + +fn os_windows() -> Vec { + unsafe extern "system" fn each(hwnd: HWND, out: LPARAM) -> BOOL { + // SAFETY: `out` is the `Vec` passed below, alive for the whole call. + let out = unsafe { &mut *(out.0 as *mut Vec) }; + out.extend(describe(hwnd)); + BOOL(1) + } + let mut out = Vec::new(); + // SAFETY: the callback only touches the `Vec` passed as the parameter. + let _ = unsafe { EnumWindows(Some(each), LPARAM(&mut out as *mut _ as isize)) }; + out +} + +fn displays() -> Vec { + unsafe extern "system" fn each(monitor: HMONITOR, _: HDC, _: *mut RECT, out: LPARAM) -> BOOL { + // SAFETY: `out` is the `Vec` passed below, alive for the whole call. + let out = unsafe { &mut *(out.0 as *mut Vec) }; + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + // SAFETY: `info` has its size set, as the call requires. + if unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() { + out.push(json!({ + "monitor": rect_json(&info.rcMonitor), + "work_area": rect_json(&info.rcWork), + "primary": info.dwFlags & 1 != 0, + })); + } + BOOL(1) + } + let mut out = Vec::new(); + // SAFETY: the callback only touches the `Vec` passed as the parameter. + let _ = + unsafe { EnumDisplayMonitors(None, None, Some(each), LPARAM(&mut out as *mut _ as isize)) }; + out +} + +struct WindowsVerbs; + +impl Verbs for WindowsVerbs { + fn prepare(&self, command: &mut Command, _mode: Mode) { + command.creation_flags(NO_WINDOW); + } + + fn handle(&mut self, request: &Request) -> Option> { + let hwnd = hwnd_of(request.hwnd); + Some(match request.verb.as_str() { + "os_windows" => Ok(Value::Array(os_windows())), + "displays" => Ok(Value::Array(displays())), + "activate" => { + // SAFETY: plain calls on a window handle given by the host. + unsafe { + let _ = ShowWindow(hwnd, SW_RESTORE); + Ok(json!(SetForegroundWindow(hwnd).as_bool())) + } + } + "minimize" => { + // SAFETY: as above. + Ok(json!(unsafe { ShowWindow(hwnd, SW_MINIMIZE) }.as_bool())) + } + "close" => { + // SAFETY: as above. + unsafe { PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0)) } + .map(|()| json!(true)) + .map_err(|e| e.to_string()) + } + _ => return None, + }) + } +} + +pub fn run() -> std::io::Result<()> { + std::panic::set_hook(Box::new(|info| log(&format!("panic: {info}")))); + // QEMU's user-mode network forwards the host port to the guest's own + // address, so loopback would not see it. The guest is on a private + // network of its own and has its firewall off (see `setup.ps1`). + let listener = TcpListener::bind(("0.0.0.0", crate::protocol::port()))?; + for stream in listener.incoming() { + let stream = stream?; + std::thread::spawn(move || { + if let Err(e) = crate::protocol::serve(stream, &mut WindowsVerbs) { + log(&format!("connection ended: {e}")); + } + }); + } + Ok(()) +}