//! Screen capture (D11, replaces xfig's `w_capture.c`): the system's own way to grab a window
//! or an area of the screen, as PNG bytes.
//!
//! - macOS: `/usr/sbin/screencapture -i -x` (the user picks a window or drags an area; Escape
//! cancels). No unsafe.
//! - Linux/BSD: the xdg-desktop-portal Screenshot request, interactive (the desktop's own
//! selection). No unsafe.
//! - Windows: the whole virtual screen through GDI (`BitBlt`); the caller hides its window first
//! ([`needs_window_hidden`]) and the grab waits a moment for it to disappear.
//! - Web: not available.
//!
//! [`capture_region`] blocks until the user is done: call it on a thread.
/// What a capture needs from the caller.
pub const fn available() -> bool {
!cfg!(target_arch = "wasm32")
}
/// Why the command is not available (empty when it works).
pub const fn unavailable_reason() -> &'static str {
if available() {
""
} else {
"The browser cannot capture the screen from here; use the system's screenshot tool and Import Picture"
}
}
/// The caller's own window has to step aside before the grab: the system selector does not
/// hide it (macOS, Linux) or there is none (Windows grabs the whole screen).
pub const fn needs_window_hidden() -> bool {
available()
}
/// Largest image taken (pixels per side), so a bogus screen size cannot allocate without limit.
const MAX_EDGE: usize = 16_384;
/// Let the user pick a window or an area of the screen; the PNG bytes, or `None` when the user
/// cancelled. Blocks (macOS and Linux wait for the user, Windows for the window to hide).
pub fn capture_region() -> Result>, String> {
platform_capture()
}
#[cfg(target_arch = "wasm32")]
fn platform_capture() -> Result >, String> {
Err(unavailable_reason().to_string())
}
/// A file name in the temporary folder that no other capture of this process uses.
#[cfg(target_os = "macos")]
fn temp_png() -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
std::env::temp_dir().join(format!("rustfig-capture-{}-{stamp}.png", std::process::id()))
}
/// The PNG the capture program wrote; `None` when there is none or it is empty (cancelled).
/// The file is removed.
#[cfg(any(
target_os = "macos",
target_os = "linux",
target_os = "freebsd",
target_os = "dragonfly",
target_os = "netbsd",
target_os = "openbsd"
))]
fn take_file(path: &std::path::Path) -> Result >, String> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
// The copy is ours now; a leftover temporary file is harmless if it cannot be removed.
let _ = std::fs::remove_file(path);
if bytes.is_empty() {
return Ok(None);
}
if !is_png(&bytes) {
return Err("The capture program did not write a PNG image".into());
}
Ok(Some(bytes))
}
/// Whether the bytes start with the PNG signature.
pub fn is_png(bytes: &[u8]) -> bool {
bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a])
}
#[cfg(target_os = "macos")]
fn platform_capture() -> Result >, String> {
let path = temp_png();
let out = std::process::Command::new("/usr/sbin/screencapture")
// -i: the user picks an area (Space switches to a window); -x: no shutter sound.
.args(["-i", "-x"])
.arg(&path)
.output()
.map_err(|e| format!("Could not start screencapture: {e}"))?;
if !out.status.success() {
let _ = std::fs::remove_file(&path);
let why = String::from_utf8_lossy(&out.stderr);
let why = why.trim();
return Err(if why.is_empty() {
"screencapture failed (does RustFig have the Screen Recording permission?)".into()
} else {
format!("screencapture failed: {why}")
});
}
take_file(&path)
}
#[cfg(any(
target_os = "linux",
target_os = "freebsd",
target_os = "dragonfly",
target_os = "netbsd",
target_os = "openbsd"
))]
fn platform_capture() -> Result >, String> {
let r = pollster::block_on(async {
let req = ashpd::desktop::screenshot::Screenshot::request()
.interactive(true)
.modal(true)
.send()
.await?;
req.response()
});
match r {
Ok(shot) => {
let path = shot.uri().to_file_path().map_err(|()| {
format!(
"The portal gave a screenshot address that is not a file: {}",
shot.uri()
)
})?;
take_file(&path)
}
Err(ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled)) => Ok(None),
Err(ashpd::Error::PortalNotFound(_)) => {
Err("Capturing needs xdg-desktop-portal with a Screenshot backend, which was not found".into())
}
Err(e) => Err(format!("The screenshot failed: {e}")),
}
}
#[cfg(target_os = "windows")]
fn platform_capture() -> Result >, String> {
// The caller's window is minimising; its animation takes a moment.
std::thread::sleep(std::time::Duration::from_millis(500));
let (w, h, rgb) = win::grab_screen()?;
encode_png_rgb(w, h, &rgb).map(Some)
}
#[cfg(target_os = "windows")]
mod win {
use super::MAX_EDGE;
use std::ffi::c_void;
use windows_sys::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, ReleaseDC,
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, CAPTUREBLT, DIB_RGB_COLORS, SRCCOPY,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
/// The whole virtual screen as width, height and RGB bytes.
#[allow(unsafe_code)]
pub fn grab_screen() -> Result<(usize, usize, Vec), String> {
// SAFETY: plain Win32 calls; every handle is checked and released, the bitmap info and the
// pixel buffer are valid for the sizes passed (buffer = width * height * 4 bytes).
unsafe {
let (x, y) = (GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN));
let (w, h) = (
GetSystemMetrics(SM_CXVIRTUALSCREEN),
GetSystemMetrics(SM_CYVIRTUALSCREEN),
);
if w <= 0 || h <= 0 || w as usize > MAX_EDGE || h as usize > MAX_EDGE {
return Err("The screen size is not usable".into());
}
let screen = GetDC(std::ptr::null_mut());
if screen.is_null() {
return Err("Could not read the screen".into());
}
let mem = CreateCompatibleDC(screen);
let bmp = CreateCompatibleBitmap(screen, w, h);
let result = if mem.is_null() || bmp.is_null() {
Err("Could not make a bitmap for the screen".to_string())
} else {
let old = SelectObject(mem, bmp);
let mut out = Err("Could not copy the screen".to_string());
if BitBlt(mem, 0, 0, w, h, screen, x, y, SRCCOPY | CAPTUREBLT) != 0 {
let mut info: BITMAPINFO = std::mem::zeroed();
info.bmiHeader = BITMAPINFOHEADER {
biSize: std::mem::size_of::() as u32,
biWidth: w,
biHeight: -h, // top-down
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: 0,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
};
let (wu, hu) = (w as usize, h as usize);
let mut bgra = vec![0u8; wu * hu * 4];
let lines = GetDIBits(
mem,
bmp,
0,
h as u32,
bgra.as_mut_ptr().cast::(),
&mut info,
DIB_RGB_COLORS,
);
if lines != 0 {
let rgb = bgra
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| [p[2], p[1], p[0]])
.collect();
out = Ok((wu, hu, rgb));
}
}
SelectObject(mem, old);
out
};
if !bmp.is_null() {
DeleteObject(bmp);
}
if !mem.is_null() {
DeleteDC(mem);
}
ReleaseDC(std::ptr::null_mut(), screen);
result
}
}
}
/// An 8-bit RGB image as a PNG (uncompressed deflate blocks: no compression library needed; the
/// file is only an intermediate the user's figure links to). Errors for sizes that do not match.
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
pub fn encode_png_rgb(width: usize, height: usize, rgb: &[u8]) -> Result, String> {
if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE || rgb.len() != width * height * 3 {
return Err("The image size is not usable".into());
}
// Scanlines: filter byte 0 + the row.
let row = width * 3;
let mut raw = Vec::with_capacity(height * (row + 1));
for line in rgb.chunks_exact(row) {
raw.push(0);
raw.extend_from_slice(line);
}
// zlib: header, stored blocks of at most 65535 bytes, Adler-32.
let mut z = vec![0x78, 0x01];
let mut blocks = raw.chunks(65_535).peekable();
while let Some(b) = blocks.next() {
z.push(u8::from(blocks.peek().is_none()));
let n = b.len() as u16;
z.extend_from_slice(&n.to_le_bytes());
z.extend_from_slice(&(!n).to_le_bytes());
z.extend_from_slice(b);
}
z.extend_from_slice(&adler32(&raw).to_be_bytes());
let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
let mut ihdr = Vec::with_capacity(13);
ihdr.extend_from_slice(&(width as u32).to_be_bytes());
ihdr.extend_from_slice(&(height as u32).to_be_bytes());
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); // 8 bit, truecolour, deflate, no filter, no interlace
chunk(&mut png, b"IHDR", &ihdr);
chunk(&mut png, b"IDAT", &z);
chunk(&mut png, b"IEND", &[]);
Ok(png)
}
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
let start = out.len();
out.extend_from_slice(kind);
out.extend_from_slice(data);
let crc = crc32(&out[start..]);
out.extend_from_slice(&crc.to_be_bytes());
}
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
fn adler32(data: &[u8]) -> u32 {
let (mut a, mut b) = (1u32, 0u32);
for &d in data {
a = (a + u32::from(d)) % 65_521;
b = (b + a) % 65_521;
}
(b << 16) | a
}
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffff_u32;
for &d in data {
crc ^= u32::from(d);
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xedb8_8320
} else {
crc >> 1
};
}
}
!crc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc_and_adler_known_values() {
assert_eq!(crc32(b"123456789"), 0xcbf4_3926);
assert_eq!(adler32(b"Wikipedia"), 0x11e6_0398);
}
#[test]
fn encoded_png_has_the_signature_and_chunks() {
let png = encode_png_rgb(2, 1, &[255, 0, 0, 0, 255, 0]).unwrap();
assert!(is_png(&png));
assert_eq!(&png[12..16], b"IHDR");
assert_eq!(&png[png.len() - 8..png.len() - 4], b"IEND");
}
#[test]
fn encoder_rejects_bad_sizes() {
assert!(encode_png_rgb(0, 1, &[]).is_err());
assert!(encode_png_rgb(2, 2, &[0; 5]).is_err());
assert!(encode_png_rgb(MAX_EDGE + 1, 1, &[]).is_err());
}
#[test]
fn large_images_use_several_blocks() {
let png = encode_png_rgb(300, 300, &vec![7u8; 300 * 300 * 3]).unwrap();
assert!(is_png(&png));
}
#[test]
fn availability_and_reason_agree() {
assert_eq!(available(), unavailable_reason().is_empty());
}
#[cfg(unix)]
#[test]
fn missing_or_empty_file_means_cancelled() {
#[cfg(not(target_arch = "wasm32"))]
{
let dir = std::env::temp_dir();
let p = dir.join(format!("rustfig-capture-test-{}-none.png", std::process::id()));
assert_eq!(take_file(&p).unwrap(), None);
std::fs::write(&p, b"").unwrap();
assert_eq!(take_file(&p).unwrap(), None);
std::fs::write(&p, b"not a png").unwrap();
assert!(take_file(&p).is_err());
let _ = std::fs::remove_file(&p);
}
}
}
.