#![cfg(target_os = "windows")]
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT, TRUE};
use windows::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject,
GetDC, GetDIBits, ReleaseDC, SelectObject,
BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, SRCCOPY,
};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetWindowRect, GetWindowTextLengthW, GetWindowTextW,
IsWindowVisible,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DualCaptureConfig {
pub primary_title: String,
pub secondary_title: String,
pub output_dir: PathBuf,
pub resize: Option<(u32, u32)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowCapture {
pub path: PathBuf,
pub title: String,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DualCaptureResult {
pub primary: Option<WindowCapture>,
pub secondary: Option<WindowCapture>,
pub primary_error: Option<String>,
pub secondary_error: Option<String>,
}
struct FoundWindow {
hwnd: HWND,
title: String,
}
fn find_windows_by_title(substring: &str) -> Vec<FoundWindow> {
struct EnumState {
pattern: String,
results: Vec<FoundWindow>,
}
let mut state = EnumState {
pattern: substring.to_lowercase(),
results: Vec::new(),
};
unsafe extern "system" fn callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let state = &mut *(lparam.0 as *mut EnumState);
if !IsWindowVisible(hwnd).as_bool() {
return TRUE;
}
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return TRUE;
}
let mut buf = vec![0u16; (len + 1) as usize];
let actual = GetWindowTextW(hwnd, &mut buf);
if actual > 0 {
let title = String::from_utf16_lossy(&buf[..actual as usize]);
if title.to_lowercase().contains(&state.pattern) {
state.results.push(FoundWindow { hwnd, title });
}
}
TRUE
}
unsafe {
let _ = EnumWindows(
Some(callback),
LPARAM(&mut state as *mut EnumState as isize),
);
}
state.results
}
pub fn list_visible_windows() -> Vec<String> {
struct EnumState {
titles: Vec<String>,
}
let mut state = EnumState {
titles: Vec::new(),
};
unsafe extern "system" fn callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
let state = &mut *(lparam.0 as *mut EnumState);
if !IsWindowVisible(hwnd).as_bool() {
return TRUE;
}
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return TRUE;
}
let mut buf = vec![0u16; (len + 1) as usize];
let actual = GetWindowTextW(hwnd, &mut buf);
if actual > 0 {
let title = String::from_utf16_lossy(&buf[..actual as usize]);
if !title.trim().is_empty() {
state.titles.push(title);
}
}
TRUE
}
unsafe {
let _ = EnumWindows(
Some(callback),
LPARAM(&mut state as *mut EnumState as isize),
);
}
state.titles
}
fn get_window_bounds(hwnd: HWND) -> Option<RECT> {
let mut rect = RECT::default();
let ok = unsafe { GetWindowRect(hwnd, &mut rect) };
if ok.is_ok() {
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
if w >= 10 && h >= 10 {
return Some(rect);
}
}
None
}
fn capture_window_to_bmp(hwnd: HWND, rect: &RECT, path: &Path) -> Result<(u32, u32), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
if w <= 0 || h <= 0 {
return Err(format!("window too small: {}x{}", w, h));
}
unsafe {
let hdc_screen = GetDC(None);
let hdc_mem = CreateCompatibleDC(hdc_screen);
let bmp = CreateCompatibleBitmap(hdc_screen, w, h);
let old = SelectObject(hdc_mem, bmp);
let _ = BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, rect.left, rect.top, SRCCOPY);
let row_bytes = ((w * 3 + 3) / 4) * 4;
let pixel_size = (row_bytes * h) as usize;
let mut pixels = vec![0u8; pixel_size];
let mut bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w,
biHeight: h,
biPlanes: 1,
biBitCount: 24,
biCompression: BI_RGB.0 as u32,
biSizeImage: pixel_size as u32,
..Default::default()
},
..Default::default()
};
GetDIBits(
hdc_mem, bmp, 0, h as u32,
Some(pixels.as_mut_ptr() as _),
&mut bmi, DIB_RGB_COLORS,
);
SelectObject(hdc_mem, old);
let _ = DeleteObject(bmp);
let _ = DeleteDC(hdc_mem);
ReleaseDC(None, hdc_screen);
let file_size = 54 + pixel_size as u32;
let mut buf = Vec::with_capacity(file_size as usize);
buf.extend_from_slice(b"BM");
buf.extend_from_slice(&file_size.to_le_bytes());
buf.extend_from_slice(&[0u8; 4]);
buf.extend_from_slice(&54u32.to_le_bytes());
buf.extend_from_slice(&40u32.to_le_bytes());
buf.extend_from_slice(&w.to_le_bytes());
buf.extend_from_slice(&h.to_le_bytes());
buf.extend_from_slice(&1u16.to_le_bytes());
buf.extend_from_slice(&24u16.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&(pixel_size as u32).to_le_bytes());
buf.extend_from_slice(&2835u32.to_le_bytes());
buf.extend_from_slice(&2835u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&pixels);
std::fs::write(path, &buf).map_err(|e| e.to_string())?;
Ok((w as u32, h as u32))
}
}
fn capture_by_title(
title_substring: &str,
output_path: &Path,
) -> Result<WindowCapture, String> {
let windows = find_windows_by_title(title_substring);
if windows.is_empty() {
return Err(format!("no window matching '{}'", title_substring));
}
let found = &windows[0];
let rect = get_window_bounds(found.hwnd)
.ok_or_else(|| format!("window '{}' too small or invisible", found.title))?;
let (w, h) = capture_window_to_bmp(found.hwnd, &rect, output_path)?;
Ok(WindowCapture {
path: output_path.to_path_buf(),
title: found.title.clone(),
width: w,
height: h,
})
}
pub fn dual_capture(config: &DualCaptureConfig) -> DualCaptureResult {
std::fs::create_dir_all(&config.output_dir).ok();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let primary_path = config.output_dir.join(format!("primary_{}.bmp", ts));
let (primary, primary_error) = match capture_by_title(&config.primary_title, &primary_path) {
Ok(cap) => (Some(cap), None),
Err(e) => (None, Some(e)),
};
let secondary_path = config.output_dir.join(format!("secondary_{}.bmp", ts));
let (secondary, secondary_error) =
match capture_by_title(&config.secondary_title, &secondary_path) {
Ok(cap) => (Some(cap), None),
Err(e) => (None, Some(e)),
};
DualCaptureResult {
primary,
secondary,
primary_error,
secondary_error,
}
}
pub fn dual_capture_json(config: &DualCaptureConfig) -> Value {
let result = dual_capture(config);
let primary_json = match &result.primary {
Some(cap) => json!({
"path": cap.path.to_string_lossy(),
"title": cap.title,
"width": cap.width,
"height": cap.height,
}),
None => json!({
"error": result.primary_error.as_deref().unwrap_or("unknown error"),
}),
};
let secondary_json = match &result.secondary {
Some(cap) => json!({
"path": cap.path.to_string_lossy(),
"title": cap.title,
"width": cap.width,
"height": cap.height,
}),
None => json!({
"error": result.secondary_error.as_deref().unwrap_or("unknown error"),
}),
};
json!({
"primary": primary_json,
"secondary": secondary_json,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_windows_returns_vec() {
let titles = list_visible_windows();
let _ = titles.len();
}
#[test]
fn find_windows_nonexistent_returns_empty() {
let results = find_windows_by_title("__nonexistent_window_title_12345__");
assert!(results.is_empty());
}
#[test]
fn dual_capture_config_serde_roundtrip() {
let config = DualCaptureConfig {
primary_title: "App A".to_string(),
secondary_title: "App B".to_string(),
output_dir: PathBuf::from("screenshots"),
resize: Some((640, 360)),
};
let json_str = serde_json::to_string(&config).unwrap();
let decoded: DualCaptureConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(decoded.primary_title, "App A");
assert_eq!(decoded.secondary_title, "App B");
assert_eq!(decoded.resize, Some((640, 360)));
}
#[test]
fn dual_capture_missing_windows_returns_errors() {
let config = DualCaptureConfig {
primary_title: "__no_such_window_primary__".to_string(),
secondary_title: "__no_such_window_secondary__".to_string(),
output_dir: PathBuf::from("test_output_dual"),
resize: None,
};
let result = dual_capture(&config);
assert!(result.primary.is_none());
assert!(result.secondary.is_none());
assert!(result.primary_error.is_some());
assert!(result.secondary_error.is_some());
}
#[test]
fn dual_capture_json_structure() {
let config = DualCaptureConfig {
primary_title: "__no_such_window__".to_string(),
secondary_title: "__no_such_window__".to_string(),
output_dir: PathBuf::from("test_output_dual"),
resize: None,
};
let json = dual_capture_json(&config);
assert!(json.get("primary").is_some());
assert!(json.get("secondary").is_some());
assert!(json["primary"].get("error").is_some());
assert!(json["secondary"].get("error").is_some());
}
}