Skip to content

Commit 978f11f

Browse files
committed
feat: screenshot api in electron(tauri not tested)
1 parent f8018b2 commit 978f11f

5 files changed

Lines changed: 231 additions & 3 deletions

File tree

src-electron/main-window-ipc.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,18 @@ function registerWindowIpcHandlers() {
370370
win.close();
371371
}
372372
});
373+
374+
// Capture a region of the current window as PNG
375+
// rect is optional {x, y, width, height} — if omitted, captures the full visible page
376+
ipcMain.handle('capture-page', async (event, rect) => {
377+
assertTrusted(event);
378+
const win = BrowserWindow.fromWebContents(event.sender);
379+
if (!win) {
380+
throw new Error('No window found for capture');
381+
}
382+
const nativeImage = await event.sender.capturePage(rect);
383+
return nativeImage.toPNG();
384+
});
373385
}
374386

375387
function getWindowLabel(webContentsId) {

src-electron/preload.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
139139
setWindowTitle: (title) => ipcRenderer.invoke('set-window-title', title),
140140
getWindowTitle: () => ipcRenderer.invoke('get-window-title'),
141141

142+
// Screenshot API — capture a region of the current window as PNG buffer
143+
// rect is optional {x, y, width, height} — if omitted, captures the full visible page
144+
capturePage: (rect) => ipcRenderer.invoke('capture-page', rect),
145+
142146
// Clipboard APIs
143147
clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
144148
clipboardWriteText: (text) => ipcRenderer.invoke('clipboard-write-text', text),

src-tauri/Cargo.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ webbrowser = "1.0"
2828
backtrace = "0.3.73"
2929
serde = { version = "1.0.200", features = ["derive"] }
3030
tauri = { version = "1.6.2", features = [ "updater", "cli", "api-all", "devtools", "linux-protocol-headers"] }
31-
winapi = { version = "0.3.9", features = ["fileapi"] }
31+
winapi = { version = "0.3.9", features = ["fileapi", "wingdi", "winuser", "windef"] }
32+
png = "0.17"
3233
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
3334
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
3435
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
@@ -50,6 +51,8 @@ dialog = "0.3.0"
5051

5152
[target.'cfg(target_os = "macos")'.dependencies]
5253
objc = "0.2.7"
54+
block = "0.1"
55+
cocoa = "0.25"
5356
tauri-plugin-deep-link = "0.1.2"
5457
lazy_static = "1.4"
5558
native-dialog = "0.7.0"

src-tauri/src/main.rs

Lines changed: 207 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ extern crate webkit2gtk;
2525
#[macro_use]
2626
extern crate objc;
2727

28+
#[cfg(target_os = "macos")]
29+
extern crate cocoa;
30+
2831
use clipboard_files;
2932

3033
#[cfg(target_os = "linux")]
@@ -387,6 +390,209 @@ fn get_mac_deep_link_requests() -> Vec<String> {
387390
}
388391
}
389392

393+
// Screenshot capture types
394+
#[derive(serde::Deserialize)]
395+
struct CaptureRect {
396+
x: f64,
397+
y: f64,
398+
width: f64,
399+
height: f64,
400+
}
401+
402+
#[tauri::command]
403+
async fn capture_page(window: tauri::Window, rect: Option<CaptureRect>) -> Result<Vec<u8>, String> {
404+
#[cfg(target_os = "linux")]
405+
{
406+
let _ = (&window, &rect);
407+
return Err("capture_page is not implemented on Linux".to_string());
408+
}
409+
410+
#[cfg(target_os = "macos")]
411+
{
412+
let (tx, rx) = tokio::sync::oneshot::channel::<Result<Vec<u8>, String>>();
413+
414+
let _ = window.with_webview(move |webview| {
415+
unsafe {
416+
let wk_webview = webview.inner();
417+
418+
// Create WKSnapshotConfiguration
419+
let config: *mut objc::runtime::Object = msg_send![class!(WKSnapshotConfiguration), new];
420+
421+
// Set capture rect if provided
422+
if let Some(r) = &rect {
423+
// CGRect layout: {origin.x, origin.y, size.width, size.height}
424+
#[repr(C)]
425+
struct CGRect { x: f64, y: f64, w: f64, h: f64 }
426+
unsafe impl objc::Encode for CGRect {
427+
fn encode() -> objc::Encoding {
428+
unsafe { objc::Encoding::from_str("{CGRect={CGPoint=dd}{CGSize=dd}}") }
429+
}
430+
}
431+
let cg_rect = CGRect { x: r.x, y: r.y, w: r.width, h: r.height };
432+
let () = msg_send![config, setRect: cg_rect];
433+
}
434+
435+
// Wrap sender in Arc<Mutex<Option>> so the closure is Fn (not FnOnce)
436+
let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx)));
437+
438+
let handler = block::ConcreteBlock::new(move |image: cocoa::base::id, error: cocoa::base::id| {
439+
let mut guard = tx.lock().unwrap();
440+
let tx = match guard.take() {
441+
Some(tx) => tx,
442+
None => return,
443+
};
444+
if !image.is_null() {
445+
// NSImage -> TIFF -> NSBitmapImageRep -> PNG
446+
let tiff_data: cocoa::base::id = msg_send![image, TIFFRepresentation];
447+
if tiff_data.is_null() {
448+
let _ = tx.send(Err("Failed to get TIFF representation".to_string()));
449+
return;
450+
}
451+
let bitmap_rep: cocoa::base::id = msg_send![
452+
class!(NSBitmapImageRep), imageRepWithData: tiff_data
453+
];
454+
if bitmap_rep.is_null() {
455+
let _ = tx.send(Err("Failed to create bitmap representation".to_string()));
456+
return;
457+
}
458+
let empty_dict: cocoa::base::id = msg_send![class!(NSDictionary), dictionary];
459+
// NSBitmapImageFileTypePNG = 4
460+
let png_data: cocoa::base::id = msg_send![
461+
bitmap_rep, representationUsingType: 4usize properties: empty_dict
462+
];
463+
if png_data.is_null() {
464+
let _ = tx.send(Err("Failed to create PNG data".to_string()));
465+
return;
466+
}
467+
let length: usize = msg_send![png_data, length];
468+
let bytes: *const u8 = msg_send![png_data, bytes];
469+
let vec = std::slice::from_raw_parts(bytes, length).to_vec();
470+
let _ = tx.send(Ok(vec));
471+
} else {
472+
let err_msg = if !error.is_null() {
473+
let desc: cocoa::base::id = msg_send![error, localizedDescription];
474+
let utf8: *const std::os::raw::c_char = msg_send![desc, UTF8String];
475+
if !utf8.is_null() {
476+
std::ffi::CStr::from_ptr(utf8).to_string_lossy().to_string()
477+
} else {
478+
"Screenshot failed".to_string()
479+
}
480+
} else {
481+
"Screenshot failed with unknown error".to_string()
482+
};
483+
let _ = tx.send(Err(err_msg));
484+
}
485+
});
486+
let handler = handler.copy();
487+
let completion_handler: &block::Block<(cocoa::base::id, cocoa::base::id), ()> = &handler;
488+
489+
let () = msg_send![
490+
wk_webview,
491+
takeSnapshotWithConfiguration: config
492+
completionHandler: completion_handler
493+
];
494+
}
495+
}).map_err(|e| e.to_string())?;
496+
497+
return rx.await.map_err(|_| "Capture channel closed".to_string())?;
498+
}
499+
500+
#[cfg(windows)]
501+
{
502+
return capture_page_windows(window, rect);
503+
}
504+
}
505+
506+
#[cfg(windows)]
507+
fn capture_page_windows(window: tauri::Window, rect: Option<CaptureRect>) -> Result<Vec<u8>, String> {
508+
use winapi::um::winuser::{GetDC, ReleaseDC, GetClientRect, PrintWindow};
509+
use winapi::um::wingdi::{
510+
CreateCompatibleDC, CreateCompatibleBitmap, SelectObject, GetDIBits,
511+
DeleteDC, DeleteObject, BITMAPINFOHEADER, BITMAPINFO, BI_RGB, DIB_RGB_COLORS,
512+
};
513+
use winapi::shared::windef::{RECT, HGDIOBJ};
514+
515+
unsafe {
516+
let hwnd = window.hwnd().map_err(|e| e.to_string())?;
517+
let hwnd = hwnd.0 as winapi::shared::windef::HWND;
518+
519+
let mut client_rect: RECT = std::mem::zeroed();
520+
GetClientRect(hwnd, &mut client_rect);
521+
let full_width = client_rect.right - client_rect.left;
522+
let full_height = client_rect.bottom - client_rect.top;
523+
524+
if full_width <= 0 || full_height <= 0 {
525+
return Err("Window has zero client area".to_string());
526+
}
527+
528+
let hdc_screen = GetDC(hwnd);
529+
let hdc_mem = CreateCompatibleDC(hdc_screen);
530+
let hbitmap = CreateCompatibleBitmap(hdc_screen, full_width, full_height);
531+
let old_bitmap = SelectObject(hdc_mem, hbitmap as HGDIOBJ);
532+
533+
// PW_CLIENTONLY=1 | PW_RENDERFULLCONTENT=2 (captures HW-accelerated content)
534+
PrintWindow(hwnd, hdc_mem, 1 | 2);
535+
536+
let mut bmi: BITMAPINFO = std::mem::zeroed();
537+
bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
538+
bmi.bmiHeader.biWidth = full_width;
539+
bmi.bmiHeader.biHeight = -full_height; // negative = top-down
540+
bmi.bmiHeader.biPlanes = 1;
541+
bmi.bmiHeader.biBitCount = 32;
542+
bmi.bmiHeader.biCompression = BI_RGB;
543+
544+
let mut pixels = vec![0u8; (full_width * full_height * 4) as usize];
545+
GetDIBits(
546+
hdc_mem, hbitmap, 0, full_height as u32,
547+
pixels.as_mut_ptr() as *mut _,
548+
&mut bmi, DIB_RGB_COLORS,
549+
);
550+
551+
SelectObject(hdc_mem, old_bitmap);
552+
DeleteObject(hbitmap as HGDIOBJ);
553+
DeleteDC(hdc_mem);
554+
ReleaseDC(hwnd, hdc_screen);
555+
556+
// BGRA -> RGBA
557+
for chunk in pixels.chunks_exact_mut(4) {
558+
chunk.swap(0, 2);
559+
}
560+
561+
// Extract the requested region (or full image)
562+
let (cap_x, cap_y, cap_w, cap_h) = if let Some(r) = &rect {
563+
let x = (r.x as i32).max(0).min(full_width);
564+
let y = (r.y as i32).max(0).min(full_height);
565+
let w = (r.width as i32).min(full_width - x).max(0);
566+
let h = (r.height as i32).min(full_height - y).max(0);
567+
(x, y, w, h)
568+
} else {
569+
(0, 0, full_width, full_height)
570+
};
571+
572+
if cap_w <= 0 || cap_h <= 0 {
573+
return Err("Capture region is empty".to_string());
574+
}
575+
576+
let mut region_pixels = Vec::with_capacity((cap_w * cap_h * 4) as usize);
577+
for y in cap_y..(cap_y + cap_h) {
578+
let start = ((y * full_width + cap_x) * 4) as usize;
579+
let end = start + (cap_w * 4) as usize;
580+
region_pixels.extend_from_slice(&pixels[start..end]);
581+
}
582+
583+
// Encode as PNG
584+
let mut png_bytes: Vec<u8> = Vec::new();
585+
{
586+
let mut encoder = png::Encoder::new(&mut png_bytes, cap_w as u32, cap_h as u32);
587+
encoder.set_color(png::ColorType::Rgba);
588+
encoder.set_depth(png::BitDepth::Eight);
589+
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
590+
writer.write_image_data(&region_pixels).map_err(|e| e.to_string())?;
591+
}
592+
Ok(png_bytes)
593+
}
594+
}
595+
390596
const PHOENIX_CRED_PREFIX: &str = "phcode_";
391597

392598
fn get_username() -> String {
@@ -627,7 +833,7 @@ fn main() {
627833
put_item, get_item, get_all_items, delete_item,
628834
trust_window_aes_key, remove_trust_window_aes_key,
629835
_get_windows_drives, _rename_path, show_in_folder, move_to_trash, zoom_window,
630-
_get_clipboard_files, _open_url_in_browser_win])
836+
_get_clipboard_files, _open_url_in_browser_win, capture_page])
631837
.setup(|app| {
632838
init::init_app(app);
633839
#[cfg(target_os = "linux")]

0 commit comments

Comments
 (0)