feat(client): implement Windows client (Phase 2)
- Persistent shell session (cmd.exe) preserving cd state between commands - Screenshot capture via Windows GDI (BGRA→RGBA→PNG→Base64) - Mouse click via SendInput with absolute screen coordinates - Text input via SendInput with Unicode (UTF-16) key events - Auto-reconnect with exponential backoff (max 30s) - Config stored in %APPDATA%/helios-remote/config.json - All Windows APIs under #[cfg(windows)] for cross-compile safety - CI: add Windows cross-compile job (x86_64-pc-windows-gnu) with artifact upload
This commit is contained in:
parent
c2ff818506
commit
04527ae1bf
6 changed files with 793 additions and 7 deletions
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -3,6 +3,7 @@ name: CI
|
|||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
|
@ -23,3 +24,36 @@ jobs:
|
|||
|
||||
- name: Test
|
||||
run: cargo test --workspace --verbose
|
||||
|
||||
build-windows-client:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust (stable) + Windows target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: windows-gnu
|
||||
|
||||
- name: Build Windows client (cross-compile)
|
||||
run: |
|
||||
cargo build --release --package helios-client --target x86_64-pc-windows-gnu
|
||||
env:
|
||||
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
|
||||
|
||||
- name: Upload Windows client artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: helios-remote-client-windows
|
||||
path: target/x86_64-pc-windows-gnu/release/helios-client.exe
|
||||
if-no-files-found: error
|
||||
|
|
|
|||
|
|
@ -3,8 +3,27 @@ name = "helios-client"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Phase 2 — Windows client (not yet implemented)
|
||||
# See README.md in this crate for the planned implementation.
|
||||
[[bin]]
|
||||
name = "helios-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.21", features = ["connect"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
helios-common = { path = "../common" }
|
||||
dirs = "5"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
base64 = "0.22"
|
||||
png = "0.17"
|
||||
futures-util = "0.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.54", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
|
|
|||
154
crates/client/src/input.rs
Normal file
154
crates/client/src/input.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/// Mouse click and keyboard input via Windows SendInput (or stub on non-Windows).
|
||||
use helios_common::MouseButton;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn click(x: i32, y: i32, button: &MouseButton) -> Result<(), String> {
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN,
|
||||
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
|
||||
MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEINPUT,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
|
||||
|
||||
unsafe {
|
||||
let screen_w = GetSystemMetrics(SM_CXSCREEN) as i32;
|
||||
let screen_h = GetSystemMetrics(SM_CYSCREEN) as i32;
|
||||
|
||||
if screen_w == 0 || screen_h == 0 {
|
||||
return Err(format!(
|
||||
"Could not get screen dimensions: {screen_w}x{screen_h}"
|
||||
));
|
||||
}
|
||||
|
||||
// Convert pixel coords to absolute 0-65535 range
|
||||
let abs_x = ((x * 65535) / screen_w) as i32;
|
||||
let abs_y = ((y * 65535) / screen_h) as i32;
|
||||
|
||||
let (down_flag, up_flag) = match button {
|
||||
MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
|
||||
MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
|
||||
MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
|
||||
};
|
||||
|
||||
// Move to position
|
||||
let move_input = INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx: abs_x,
|
||||
dy: abs_y,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let down_input = INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx: abs_x,
|
||||
dy: abs_y,
|
||||
mouseData: 0,
|
||||
dwFlags: down_flag | MOUSEEVENTF_ABSOLUTE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let up_input = INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx: abs_x,
|
||||
dy: abs_y,
|
||||
mouseData: 0,
|
||||
dwFlags: up_flag | MOUSEEVENTF_ABSOLUTE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let inputs = [move_input, down_input, up_input];
|
||||
let result = SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
|
||||
|
||||
if result != inputs.len() as u32 {
|
||||
return Err(format!(
|
||||
"SendInput for click at ({x},{y}) sent {result}/{} events — some may have been blocked by UIPI",
|
||||
inputs.len()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn type_text(text: &str) -> Result<(), String> {
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_UNICODE,
|
||||
};
|
||||
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut inputs: Vec<INPUT> = Vec::with_capacity(text.len() * 2);
|
||||
|
||||
for ch in text.encode_utf16() {
|
||||
// Key down
|
||||
inputs.push(INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
|
||||
wScan: ch,
|
||||
dwFlags: KEYEVENTF_UNICODE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Key up
|
||||
inputs.push(INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY(0),
|
||||
wScan: ch,
|
||||
dwFlags: KEYEVENTF_UNICODE
|
||||
| windows::Win32::UI::Input::KeyboardAndMouse::KEYEVENTF_KEYUP,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let result = SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
|
||||
|
||||
if result != inputs.len() as u32 {
|
||||
return Err(format!(
|
||||
"SendInput for type_text sent {result}/{} events — some may have been blocked (UIPI or secure desktop)",
|
||||
inputs.len()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn click(_x: i32, _y: i32, _button: &MouseButton) -> Result<(), String> {
|
||||
Err("click() is only supported on Windows".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn type_text(_text: &str) -> Result<(), String> {
|
||||
Err("type_text() is only supported on Windows".to_string())
|
||||
}
|
||||
|
|
@ -1,7 +1,279 @@
|
|||
// helios-client — Phase 2 (not yet implemented)
|
||||
// See crates/client/README.md for the planned implementation.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
eprintln!("helios-client is not yet implemented. See crates/client/README.md.");
|
||||
std::process::exit(1);
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use helios_common::{ClientMessage, ServerMessage};
|
||||
|
||||
mod shell;
|
||||
mod screenshot;
|
||||
mod input;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Config {
|
||||
relay_url: String,
|
||||
relay_code: String,
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn config_path() -> PathBuf {
|
||||
let base = dirs::config_dir()
|
||||
.or_else(|| dirs::home_dir())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("helios-remote").join("config.json")
|
||||
}
|
||||
|
||||
fn load() -> Option<Self> {
|
||||
let path = Self::config_path();
|
||||
let data = std::fs::read_to_string(&path).ok()?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
fn save(&self) -> std::io::Result<()> {
|
||||
let path = Self::config_path();
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let data = serde_json::to_string_pretty(self).unwrap();
|
||||
std::fs::write(&path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_config() -> Config {
|
||||
let relay_url = {
|
||||
println!("Relay server URL [default: ws://46.225.185.232:8765/ws]: ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
"ws://46.225.185.232:8765/ws".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let relay_code = {
|
||||
println!("Enter relay code: ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
input.trim().to_string()
|
||||
};
|
||||
|
||||
let label = {
|
||||
println!("Label for this machine (optional, press Enter to skip): ");
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input).unwrap();
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||
};
|
||||
|
||||
Config { relay_url, relay_code, label }
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "helios_client=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// Load or prompt for config
|
||||
let config = match Config::load() {
|
||||
Some(c) => {
|
||||
info!("Loaded config from {:?}", Config::config_path());
|
||||
c
|
||||
}
|
||||
None => {
|
||||
info!("No config found — prompting for setup");
|
||||
let c = prompt_config();
|
||||
if let Err(e) = c.save() {
|
||||
error!("Failed to save config: {e}");
|
||||
} else {
|
||||
info!("Config saved to {:?}", Config::config_path());
|
||||
}
|
||||
c
|
||||
}
|
||||
};
|
||||
|
||||
let config = Arc::new(config);
|
||||
let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
|
||||
|
||||
// Connect with exponential backoff
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
info!("Connecting to {}", config.relay_url);
|
||||
match connect_async(&config.relay_url).await {
|
||||
Ok((ws_stream, _)) => {
|
||||
info!("Connected!");
|
||||
backoff = Duration::from_secs(1); // reset on success
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Send Hello
|
||||
let hello = ClientMessage::Hello {
|
||||
label: config.label.clone(),
|
||||
};
|
||||
let hello_json = serde_json::to_string(&hello).unwrap();
|
||||
if let Err(e) = write.send(Message::Text(hello_json)).await {
|
||||
error!("Failed to send Hello: {e}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Shared write half
|
||||
let write = Arc::new(Mutex::new(write));
|
||||
|
||||
// Process messages
|
||||
while let Some(msg_result) = read.next().await {
|
||||
match msg_result {
|
||||
Ok(Message::Text(text)) => {
|
||||
let server_msg: ServerMessage = match serde_json::from_str(&text) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse server message: {e}\nRaw: {text}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let write_clone = Arc::clone(&write);
|
||||
let shell_clone = Arc::clone(&shell);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let response = handle_message(server_msg, shell_clone).await;
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
let mut w = write_clone.lock().await;
|
||||
if let Err(e) = w.send(Message::Text(json)).await {
|
||||
error!("Failed to send response: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Message::Ping(data)) => {
|
||||
let mut w = write.lock().await;
|
||||
let _ = w.send(Message::Pong(data)).await;
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("Server closed connection");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {e}");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Disconnected. Reconnecting in {:?}...", backoff);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Connection failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
msg: ServerMessage,
|
||||
shell: Arc<Mutex<shell::PersistentShell>>,
|
||||
) -> ClientMessage {
|
||||
match msg {
|
||||
ServerMessage::ScreenshotRequest { request_id } => {
|
||||
match screenshot::take_screenshot() {
|
||||
Ok((image_base64, width, height)) => ClientMessage::ScreenshotResponse {
|
||||
request_id,
|
||||
image_base64,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Screenshot failed: {e}");
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!("Screenshot failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ExecRequest { request_id, command } => {
|
||||
info!("Exec: {command}");
|
||||
let mut sh = shell.lock().await;
|
||||
match sh.run(&command).await {
|
||||
Ok((stdout, stderr, exit_code)) => ClientMessage::ExecResponse {
|
||||
request_id,
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Exec failed for command {:?}: {e}", command);
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!(
|
||||
"Exec failed for command {:?}.\nError: {e}\nContext: persistent shell may have died.",
|
||||
command
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::ClickRequest { request_id, x, y, button } => {
|
||||
info!("Click: ({x},{y}) {:?}", button);
|
||||
match input::click(x, y, &button) {
|
||||
Ok(()) => ClientMessage::Ack { request_id },
|
||||
Err(e) => {
|
||||
error!("Click failed at ({x},{y}): {e}");
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!("Click at ({x},{y}) failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::TypeRequest { request_id, text } => {
|
||||
info!("Type: {} chars", text.len());
|
||||
match input::type_text(&text) {
|
||||
Ok(()) => ClientMessage::Ack { request_id },
|
||||
Err(e) => {
|
||||
error!("Type failed: {e}");
|
||||
ClientMessage::Error {
|
||||
request_id,
|
||||
message: format!("Type failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServerMessage::Ack { request_id } => {
|
||||
info!("Server ack for {request_id}");
|
||||
// Nothing to do - server acked something we sent
|
||||
ClientMessage::Ack { request_id }
|
||||
}
|
||||
|
||||
ServerMessage::Error { request_id, message } => {
|
||||
error!("Server error (req={request_id:?}): {message}");
|
||||
// No meaningful response needed but we need to return something
|
||||
// Use a dummy ack if we have a request_id
|
||||
if let Some(rid) = request_id {
|
||||
ClientMessage::Ack { request_id: rid }
|
||||
} else {
|
||||
ClientMessage::Hello { label: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
146
crates/client/src/screenshot.rs
Normal file
146
crates/client/src/screenshot.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/// Screenshot capture — Windows GDI on Windows, stub on other platforms.
|
||||
use base64::Engine;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn take_screenshot() -> Result<(String, u32, u32), String> {
|
||||
use windows::Win32::Foundation::RECT;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
|
||||
GetDIBits, GetObjectW, SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER,
|
||||
DIB_RGB_COLORS, SRCCOPY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetDesktopWindow;
|
||||
use windows::Win32::Graphics::Gdi::GetWindowDC;
|
||||
use windows::Win32::Graphics::Gdi::ReleaseDC;
|
||||
|
||||
unsafe {
|
||||
let hwnd = GetDesktopWindow();
|
||||
let hdc_screen = GetWindowDC(hwnd);
|
||||
if hdc_screen.is_invalid() {
|
||||
return Err("GetWindowDC failed — cannot capture screen".to_string());
|
||||
}
|
||||
|
||||
// Get screen dimensions
|
||||
use windows::Win32::Graphics::Gdi::{GetDeviceCaps, HORZRES, VERTRES};
|
||||
let width = GetDeviceCaps(hdc_screen, HORZRES) as u32;
|
||||
let height = GetDeviceCaps(hdc_screen, VERTRES) as u32;
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
ReleaseDC(hwnd, hdc_screen);
|
||||
return Err(format!("Invalid screen dimensions: {width}x{height}"));
|
||||
}
|
||||
|
||||
// Create compatible DC and bitmap
|
||||
let hdc_mem = CreateCompatibleDC(hdc_screen);
|
||||
if hdc_mem.is_invalid() {
|
||||
ReleaseDC(hwnd, hdc_screen);
|
||||
return Err("CreateCompatibleDC failed".to_string());
|
||||
}
|
||||
|
||||
let hbm = CreateCompatibleBitmap(hdc_screen, width as i32, height as i32);
|
||||
if hbm.is_invalid() {
|
||||
DeleteDC(hdc_mem);
|
||||
ReleaseDC(hwnd, hdc_screen);
|
||||
return Err("CreateCompatibleBitmap failed".to_string());
|
||||
}
|
||||
|
||||
let old_obj = SelectObject(hdc_mem, hbm);
|
||||
|
||||
// BitBlt the screen into our bitmap
|
||||
let blt_result = BitBlt(
|
||||
hdc_mem,
|
||||
0, 0,
|
||||
width as i32, height as i32,
|
||||
hdc_screen,
|
||||
0, 0,
|
||||
SRCCOPY,
|
||||
);
|
||||
|
||||
if blt_result.is_err() {
|
||||
SelectObject(hdc_mem, old_obj);
|
||||
DeleteObject(hbm);
|
||||
DeleteDC(hdc_mem);
|
||||
ReleaseDC(hwnd, hdc_screen);
|
||||
return Err("BitBlt failed — could not copy screen pixels".to_string());
|
||||
}
|
||||
|
||||
// Get raw pixel data via GetDIBits
|
||||
let mut bmi = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: width as i32,
|
||||
biHeight: -(height as i32), // negative = top-down
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0, // BI_RGB
|
||||
biSizeImage: 0,
|
||||
biXPelsPerMeter: 0,
|
||||
biYPelsPerMeter: 0,
|
||||
biClrUsed: 0,
|
||||
biClrImportant: 0,
|
||||
},
|
||||
bmiColors: [Default::default()],
|
||||
};
|
||||
|
||||
let buf_size = (width * height * 4) as usize;
|
||||
let mut pixel_buf: Vec<u8> = vec![0u8; buf_size];
|
||||
|
||||
let lines = GetDIBits(
|
||||
hdc_mem,
|
||||
hbm,
|
||||
0,
|
||||
height,
|
||||
Some(pixel_buf.as_mut_ptr() as *mut _),
|
||||
&mut bmi,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
|
||||
SelectObject(hdc_mem, old_obj);
|
||||
DeleteObject(hbm);
|
||||
DeleteDC(hdc_mem);
|
||||
ReleaseDC(hwnd, hdc_screen);
|
||||
|
||||
if lines == 0 {
|
||||
return Err(format!("GetDIBits failed — returned 0 scan lines (expected {height})"));
|
||||
}
|
||||
|
||||
// Convert BGRA → RGBA
|
||||
for chunk in pixel_buf.chunks_exact_mut(4) {
|
||||
chunk.swap(0, 2); // B <-> R
|
||||
}
|
||||
|
||||
// Encode as PNG
|
||||
let png_bytes = encode_png(&pixel_buf, width, height)?;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
|
||||
|
||||
Ok((b64, width, height))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn take_screenshot() -> Result<(String, u32, u32), String> {
|
||||
// Stub for non-Windows builds
|
||||
// In a real scenario, could use X11/scrot on Linux
|
||||
let width = 1u32;
|
||||
let height = 1u32;
|
||||
let pixel_data = vec![0u8, 0u8, 0u8, 255u8]; // single black pixel RGBA
|
||||
let png_bytes = encode_png(&pixel_data, width, height)?;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
|
||||
Ok((b64, width, height))
|
||||
}
|
||||
|
||||
fn encode_png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut buf, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| format!("PNG header error: {e}"))?;
|
||||
writer
|
||||
.write_image_data(rgba)
|
||||
.map_err(|e| format!("PNG write error: {e}"))?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
161
crates/client/src/shell.rs
Normal file
161
crates/client/src/shell.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/// Persistent shell session that keeps a cmd.exe (Windows) or sh (Unix) alive
|
||||
/// between commands, so state like `cd` is preserved.
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, ChildStderr};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const OUTPUT_TIMEOUT_MS: u64 = 10_000;
|
||||
/// Unique sentinel appended after every command to know when output is done.
|
||||
const SENTINEL: &str = "__HELIOS_DONE__";
|
||||
|
||||
pub struct PersistentShell {
|
||||
child: Option<ShellProcess>,
|
||||
}
|
||||
|
||||
struct ShellProcess {
|
||||
_child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout_lines: tokio::sync::Mutex<BufReader<ChildStdout>>,
|
||||
stderr_lines: tokio::sync::Mutex<BufReader<ChildStderr>>,
|
||||
}
|
||||
|
||||
impl PersistentShell {
|
||||
pub fn new() -> Self {
|
||||
Self { child: None }
|
||||
}
|
||||
|
||||
async fn spawn(&mut self) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
let (program, args) = ("cmd.exe", vec!["/Q"]);
|
||||
#[cfg(not(windows))]
|
||||
let (program, args) = ("sh", vec!["-s"]);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
for arg in &args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn shell '{program}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or("no stdin")?;
|
||||
let stdout = child.stdout.take().ok_or("no stdout")?;
|
||||
let stderr = child.stderr.take().ok_or("no stderr")?;
|
||||
|
||||
self.child = Some(ShellProcess {
|
||||
_child: child,
|
||||
stdin,
|
||||
stdout_lines: tokio::sync::Mutex::new(BufReader::new(stdout)),
|
||||
stderr_lines: tokio::sync::Mutex::new(BufReader::new(stderr)),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command in the persistent shell, returning (stdout, stderr, exit_code).
|
||||
/// exit_code is always 0 for intermediate commands; we read the exit code via `echo %ERRORLEVEL%`.
|
||||
pub async fn run(&mut self, command: &str) -> Result<(String, String, i32), String> {
|
||||
// Restart shell if it died
|
||||
if self.child.is_none() {
|
||||
self.spawn().await?;
|
||||
}
|
||||
|
||||
let result = self.run_inner(command).await;
|
||||
|
||||
match result {
|
||||
Ok(r) => Ok(r),
|
||||
Err(e) => {
|
||||
// Shell probably died — drop it and report error
|
||||
warn!("Shell error, will respawn next time: {e}");
|
||||
self.child = None;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_inner(&mut self, command: &str) -> Result<(String, String, i32), String> {
|
||||
let shell = self.child.as_mut().ok_or("no shell")?;
|
||||
|
||||
// Write command + sentinel echo to stdin
|
||||
#[cfg(windows)]
|
||||
let cmd_line = format!("{command}\r\necho {SENTINEL}%ERRORLEVEL%\r\n");
|
||||
#[cfg(not(windows))]
|
||||
let cmd_line = format!("{command}\necho {SENTINEL}$?\n");
|
||||
|
||||
debug!("Shell input: {cmd_line:?}");
|
||||
|
||||
shell
|
||||
.stdin
|
||||
.write_all(cmd_line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write to shell stdin: {e}"))?;
|
||||
shell
|
||||
.stdin
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to flush shell stdin: {e}"))?;
|
||||
|
||||
// Read stdout until we see the sentinel line
|
||||
let mut stdout_buf = String::new();
|
||||
#[allow(unused_assignments)]
|
||||
let mut exit_code = 0i32;
|
||||
|
||||
let timeout = tokio::time::Duration::from_millis(OUTPUT_TIMEOUT_MS);
|
||||
|
||||
{
|
||||
let mut reader = shell.stdout_lines.lock().await;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read_fut = reader.read_line(&mut line);
|
||||
match tokio::time::timeout(timeout, read_fut).await {
|
||||
Ok(Ok(0)) => {
|
||||
return Err("Shell stdout EOF — process likely died".to_string());
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
debug!("stdout line: {line:?}");
|
||||
if line.trim_end().starts_with(SENTINEL) {
|
||||
// Parse exit code from sentinel line
|
||||
let code_str = line.trim_end().trim_start_matches(SENTINEL);
|
||||
exit_code = code_str.trim().parse().unwrap_or(0);
|
||||
break;
|
||||
} else {
|
||||
stdout_buf.push_str(&line);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return Err(format!("Shell stdout read error: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"Shell stdout timed out after {}ms waiting for command to finish.\nCommand: {command}\nOutput so far: {stdout_buf}",
|
||||
OUTPUT_TIMEOUT_MS
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain available stderr (non-blocking)
|
||||
let mut stderr_buf = String::new();
|
||||
{
|
||||
let mut reader = shell.stderr_lines.lock().await;
|
||||
let drain_timeout = tokio::time::Duration::from_millis(100);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match tokio::time::timeout(drain_timeout, reader.read_line(&mut line)).await {
|
||||
Ok(Ok(0)) | Err(_) => break,
|
||||
Ok(Ok(_)) => stderr_buf.push_str(&line),
|
||||
Ok(Err(_)) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((stdout_buf, stderr_buf, exit_code))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue