283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
//! Remote cursor tracking (RustDesk-style overlay): report the host cursor's
|
|
//! position and shape to the viewer, sent on the control channel separately
|
|
//! from video so an idle screen still shows a live, correctly-shaped cursor.
|
|
//!
|
|
//! Shapes are reported as CSS names for the standard system cursors (arrow,
|
|
//! text, hand, resize, …) — which the viewer renders as crisp OS-native
|
|
//! pointers — so there is no fragile per-frame bitmap extraction. Non-standard
|
|
//! (app-custom) cursors fall back to the viewer's default pointer.
|
|
|
|
#![cfg(windows)]
|
|
|
|
use windows::Win32::UI::WindowsAndMessaging::{
|
|
GetCursorInfo, LoadCursorW, CURSORINFO, CURSOR_SHOWING, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS,
|
|
IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE,
|
|
IDC_SIZEWE, IDC_WAIT,
|
|
};
|
|
|
|
/// `(hcursor_or_0, global_x, global_y)`. `0` means the cursor is hidden.
|
|
pub fn cursor_state() -> Option<(u64, i32, i32)> {
|
|
unsafe {
|
|
let mut ci = CURSORINFO {
|
|
cbSize: std::mem::size_of::<CURSORINFO>() as u32,
|
|
..Default::default()
|
|
};
|
|
if GetCursorInfo(&mut ci).is_err() {
|
|
return None;
|
|
}
|
|
let showing = (ci.flags.0 & CURSOR_SHOWING.0) != 0 && !ci.hCursor.is_invalid();
|
|
let h = if showing { ci.hCursor.0 as u64 } else { 0 };
|
|
Some((h, ci.ptScreenPos.x, ci.ptScreenPos.y))
|
|
}
|
|
}
|
|
|
|
/// Map a cursor handle to a CSS cursor name by comparing it against the loaded
|
|
/// standard system cursors. Returns `None` for app-custom cursors.
|
|
pub fn cursor_css(hcursor: u64) -> Option<&'static str> {
|
|
if hcursor == 0 {
|
|
return None;
|
|
}
|
|
const STD: &[(windows::core::PCWSTR, &str)] = &[
|
|
(IDC_ARROW, "default"),
|
|
(IDC_IBEAM, "text"),
|
|
(IDC_HAND, "pointer"),
|
|
(IDC_CROSS, "crosshair"),
|
|
(IDC_WAIT, "wait"),
|
|
(IDC_APPSTARTING, "progress"),
|
|
(IDC_SIZEALL, "move"),
|
|
(IDC_SIZENS, "ns-resize"),
|
|
(IDC_SIZEWE, "ew-resize"),
|
|
(IDC_SIZENWSE, "nwse-resize"),
|
|
(IDC_SIZENESW, "nesw-resize"),
|
|
(IDC_NO, "not-allowed"),
|
|
(IDC_HELP, "help"),
|
|
];
|
|
unsafe {
|
|
for (idc, css) in STD {
|
|
if let Ok(h) = LoadCursorW(None, *idc) {
|
|
if h.0 as u64 == hcursor {
|
|
return Some(css);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// A decoded custom-cursor bitmap ready for `cursor.shape` (PNG + hotspot), so
|
|
/// app-specific cursors render on the viewer instead of falling back to the
|
|
/// default pointer. RustDesk sends the raw cursor image the same way.
|
|
pub struct CursorBitmap {
|
|
pub png: Vec<u8>,
|
|
pub w: u32,
|
|
pub h: u32,
|
|
pub hx: i32,
|
|
pub hy: i32,
|
|
}
|
|
|
|
/// Extract a (color) cursor's RGBA bitmap + hotspot and PNG-encode it. Returns
|
|
/// None for monochrome cursors (rare for app-custom shapes; the standard mono
|
|
/// ones are already mapped to CSS) and on any GDI failure, so the caller simply
|
|
/// keeps the default pointer. Mirrors capture.rs's GetDIBits usage.
|
|
pub fn cursor_bitmap(hcursor: u64) -> Option<CursorBitmap> {
|
|
use windows::Win32::Foundation::HANDLE;
|
|
use windows::Win32::Graphics::Gdi::{
|
|
DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO,
|
|
BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HGDIOBJ,
|
|
};
|
|
use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, HICON, ICONINFO};
|
|
|
|
if hcursor == 0 {
|
|
return None;
|
|
}
|
|
unsafe {
|
|
let mut ii = ICONINFO::default();
|
|
if GetIconInfo(HICON(hcursor as *mut std::ffi::c_void), &mut ii).is_err() {
|
|
return None;
|
|
}
|
|
// Always release the bitmaps GetIconInfo created for us.
|
|
let cleanup = |ii: &ICONINFO| {
|
|
if !ii.hbmColor.is_invalid() {
|
|
let _ = DeleteObject(HGDIOBJ(ii.hbmColor.0));
|
|
}
|
|
if !ii.hbmMask.is_invalid() {
|
|
let _ = DeleteObject(HGDIOBJ(ii.hbmMask.0));
|
|
}
|
|
};
|
|
if ii.hbmColor.is_invalid() {
|
|
// Monochrome cursor — fall back to the default pointer.
|
|
cleanup(&ii);
|
|
return None;
|
|
}
|
|
// Dimensions from the color bitmap.
|
|
let mut bm = BITMAP::default();
|
|
let got = GetObjectW(
|
|
HGDIOBJ(ii.hbmColor.0),
|
|
std::mem::size_of::<BITMAP>() as i32,
|
|
Some(&mut bm as *mut _ as *mut std::ffi::c_void),
|
|
);
|
|
if got == 0 || bm.bmWidth <= 0 || bm.bmHeight <= 0 || bm.bmWidth > 512 || bm.bmHeight > 512 {
|
|
cleanup(&ii);
|
|
return None;
|
|
}
|
|
let (w, h) = (bm.bmWidth as u32, bm.bmHeight as u32);
|
|
let read_bgra = |hbm: HBITMAP| -> Option<Vec<u8>> {
|
|
let screen = GetDC(None);
|
|
let mut bi = BITMAPINFO {
|
|
bmiHeader: BITMAPINFOHEADER {
|
|
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
|
biWidth: w as i32,
|
|
biHeight: -(h as i32), // top-down
|
|
biPlanes: 1,
|
|
biBitCount: 32,
|
|
biCompression: BI_RGB.0,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
let mut buf = vec![0u8; (w * h * 4) as usize];
|
|
let lines = GetDIBits(
|
|
screen,
|
|
hbm,
|
|
0,
|
|
h,
|
|
Some(buf.as_mut_ptr() as *mut std::ffi::c_void),
|
|
&mut bi,
|
|
DIB_RGB_COLORS,
|
|
);
|
|
ReleaseDC(None, screen);
|
|
if lines == 0 {
|
|
None
|
|
} else {
|
|
Some(buf)
|
|
}
|
|
};
|
|
let color = read_bgra(ii.hbmColor);
|
|
// The AND mask (same 32bpp read): nonzero means "transparent" where the
|
|
// color bitmap carries no alpha of its own.
|
|
let mask = read_bgra(ii.hbmMask);
|
|
let hotspot = (ii.xHotspot as i32, ii.yHotspot as i32);
|
|
cleanup(&ii);
|
|
let color = color?;
|
|
|
|
// BGRA -> RGBA. Use the color bitmap's own alpha if it has any; otherwise
|
|
// derive it from the AND mask (mask pixel != 0 => transparent).
|
|
let has_alpha = color.chunks_exact(4).any(|px| px[3] != 0);
|
|
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
|
for (i, px) in color.chunks_exact(4).enumerate() {
|
|
let (b, g, r) = (px[0], px[1], px[2]);
|
|
let a = if has_alpha {
|
|
px[3]
|
|
} else if let Some(m) = &mask {
|
|
// AND mask: a "set" (white) pixel is transparent.
|
|
let mp = &m[i * 4..i * 4 + 4];
|
|
if mp[0] | mp[1] | mp[2] != 0 {
|
|
0
|
|
} else {
|
|
255
|
|
}
|
|
} else {
|
|
255
|
|
};
|
|
let o = i * 4;
|
|
rgba[o] = r;
|
|
rgba[o + 1] = g;
|
|
rgba[o + 2] = b;
|
|
rgba[o + 3] = a;
|
|
}
|
|
let _ = HANDLE::default(); // keep the HANDLE import used across windows versions
|
|
let png = encode_png(&rgba, w, h)?;
|
|
Some(CursorBitmap { png, w, h, hx: hotspot.0, hy: hotspot.1 })
|
|
}
|
|
}
|
|
|
|
/// Encode a top-down RGBA buffer to PNG bytes.
|
|
pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
|
let mut out = Vec::new();
|
|
{
|
|
let mut enc = png::Encoder::new(&mut out, w, h);
|
|
enc.set_color(png::ColorType::Rgba);
|
|
enc.set_depth(png::BitDepth::Eight);
|
|
let mut writer = enc.write_header().ok()?;
|
|
writer.write_image_data(rgba).ok()?;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// Decode PNG bytes to top-down RGBA8 + (w, h). Used for clipboard images that
|
|
/// arrive from a viewer, to hand arboard an `ImageData`.
|
|
pub(crate) fn decode_png(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
|
|
let dec = png::Decoder::new(std::io::Cursor::new(bytes));
|
|
let mut reader = dec.read_info().ok()?;
|
|
let mut buf = vec![0u8; reader.output_buffer_size()];
|
|
let info = reader.next_frame(&mut buf).ok()?;
|
|
buf.truncate(info.buffer_size());
|
|
// Normalize to RGBA8.
|
|
let rgba = match (info.color_type, info.bit_depth) {
|
|
(png::ColorType::Rgba, png::BitDepth::Eight) => buf,
|
|
(png::ColorType::Rgb, png::BitDepth::Eight) => {
|
|
let mut out = Vec::with_capacity((info.width * info.height * 4) as usize);
|
|
for px in buf.chunks_exact(3) {
|
|
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
|
|
}
|
|
out
|
|
}
|
|
_ => return None, // uncommon formats -> skip (clipboard images are RGB/RGBA8)
|
|
};
|
|
Some((rgba, info.width, info.height))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Extract a real system cursor's bitmap (IDC_HAND is a color cursor on
|
|
// modern Windows). In production cursor_css() catches the standard ones
|
|
// first; here we call cursor_bitmap directly to prove the GDI extraction +
|
|
// PNG encode produce a valid, sensibly-sized image.
|
|
#[test]
|
|
fn extracts_a_real_cursor_to_png() {
|
|
let h = unsafe { LoadCursorW(None, IDC_HAND) };
|
|
let h = match h {
|
|
Ok(h) => h.0 as u64,
|
|
Err(_) => return, // no interactive cursor resource (headless CI) — skip
|
|
};
|
|
if let Some(bmp) = cursor_bitmap(h) {
|
|
assert!(bmp.w >= 8 && bmp.w <= 512, "width sane: {}", bmp.w);
|
|
assert!(bmp.h >= 8 && bmp.h <= 512, "height sane: {}", bmp.h);
|
|
// Valid PNG signature.
|
|
assert_eq!(&bmp.png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
// Round-trips through a decoder back to w*h*4 RGBA.
|
|
let dec = png::Decoder::new(std::io::Cursor::new(&bmp.png));
|
|
let mut reader = dec.read_info().expect("png header");
|
|
let mut buf = vec![0u8; reader.output_buffer_size()];
|
|
let info = reader.next_frame(&mut buf).expect("png frame");
|
|
assert_eq!(info.width, bmp.w);
|
|
assert_eq!(info.height, bmp.h);
|
|
}
|
|
// If extraction returned None (e.g. a monochrome IDC_HAND on some
|
|
// builds), that's the documented safe fallback — not a failure.
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod clip_png_tests {
|
|
use super::{decode_png, encode_png};
|
|
|
|
#[test]
|
|
fn png_rgba_roundtrip_for_clipboard_images() {
|
|
// A 2x2 RGBA image round-trips encode -> decode byte-for-byte.
|
|
let w = 2u32;
|
|
let h = 2u32;
|
|
let rgba: Vec<u8> = vec![
|
|
255, 0, 0, 255, 0, 255, 0, 255,
|
|
0, 0, 255, 128, 10, 20, 30, 40,
|
|
];
|
|
let png = encode_png(&rgba, w, h).expect("encode");
|
|
assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
let (got, gw, gh) = decode_png(&png).expect("decode");
|
|
assert_eq!((gw, gh), (w, h));
|
|
assert_eq!(got, rgba, "clipboard image pixels survive the round-trip");
|
|
}
|
|
}
|