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
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue