mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-13 12:23:38 +01:00
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.
This commit is contained in:
1 parent
0f5cfc8b99
commit
71a9018e4b
4 files changed
+441
-301
No files matched your search
@@ -8,4 +8,6 @@ publish = false
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true }
|
||||
@@ -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<String>,
|
||||
#[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<Value> {
|
||||
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::<RECT>() 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::<u32>() 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<Value> {
|
||||
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<Value>) };
|
||||
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<Value> {
|
||||
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<Value>) };
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() 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<Value, String>) {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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);
|
||||
}
|
||||
@@ -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<Option<HashMap<u32, Child>>> = Mutex::new(None);
|
||||
|
||||
fn children<T>(use_them: impl FnOnce(&mut HashMap<u32, Child>) -> 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<String>,
|
||||
#[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<String, String>,
|
||||
/// A file that gets what a spawned program prints, standard output and error.
|
||||
#[serde(default)]
|
||||
pub out: Option<String>,
|
||||
/// 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<Result<Value, String>>;
|
||||
|
||||
/// 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<Value, String>) {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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::<RECT>() 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::<u32>() 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<Value> {
|
||||
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<Value>) };
|
||||
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<Value> {
|
||||
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<Value>) };
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() 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<Result<Value, String>> {
|
||||
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(())
|
||||
}
|
||||
Reference in new issue
Block a user