diff --git a/Cargo.lock b/Cargo.lock index 2b169d85b..e7f899737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1803,6 +1803,7 @@ dependencies = [ "bitflags 2.8.0", "objc2 0.6.0", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.0", ] @@ -1852,6 +1853,18 @@ dependencies = [ "objc2 0.6.0", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02" +dependencies = [ + "bitflags 2.8.0", + "objc2 0.6.0", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -1915,6 +1928,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19" +dependencies = [ + "bitflags 2.8.0", + "objc2 0.6.0", + "objc2-core-foundation", +] + [[package]] name = "objc2-link-presentation" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index a63623dc2..c87eacd9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,6 +130,7 @@ objc2-web-kit = { version = "0.3.0", default-features = false, features = [ "WKWebpagePreferences", "WKNavigationResponse", "WKUserScript", + "WKSnapshotConfiguration", "WKHTTPCookieStore", "WKWindowFeatures", ] } @@ -190,7 +191,12 @@ objc2-app-kit = { version = "0.3.0", default-features = false, features = [ "NSSavePanel", "NSMenu", "NSGraphics", + "NSImage", + "NSImageRep", + "NSBitmapImageRep", + "NSGraphicsContext", "NSScreen", + "objc2-core-graphics", ] } [target."cfg(target_os = \"android\")".dependencies] diff --git a/examples/screenshot_smoke.rs b/examples/screenshot_smoke.rs new file mode 100644 index 000000000..ae6118fde --- /dev/null +++ b/examples/screenshot_smoke.rs @@ -0,0 +1,150 @@ +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::Duration; + +use tao::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoopBuilder}, + window::WindowBuilder, +}; +use wry::{PageLoadEvent, WebViewBuilder}; + +#[derive(Debug, Clone, Copy)] +enum UserEvent { + Capture, + Exit, +} + +fn main() -> wry::Result<()> { + let event_loop = EventLoopBuilder::::with_user_event().build(); + let proxy = event_loop.create_proxy(); + let window = WindowBuilder::new() + .with_title("wry screenshot smoke") + .build(&event_loop) + .unwrap(); + + let already_requested = Arc::new(AtomicBool::new(false)); + let already_requested_ = already_requested.clone(); + let proxy_for_load = proxy.clone(); + + let builder = WebViewBuilder::new() + .with_html( + r#" + + + + + WRY Screenshot Smoke + + + +
+

Screenshot Smoke Test

+

If you can read this in screenshot.png, capture worked.

+
+ +"#, + ) + .with_on_page_load_handler(move |event, _url| { + if matches!(event, PageLoadEvent::Finished) + && !already_requested_.swap(true, Ordering::SeqCst) + { + let proxy = proxy_for_load.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1000)); + let _ = proxy.send_event(UserEvent::Capture); + }); + } + }); + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + ))] + let webview = builder.build(&window)?; + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "ios", + target_os = "android" + )))] + let webview = { + use tao::platform::unix::WindowExtUnix; + use wry::WebViewBuilderExtUnix; + let vbox = window.default_vbox().unwrap(); + builder.build_gtk(vbox)? + }; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + Event::UserEvent(UserEvent::Capture) => { + // screenshot is not supported on Android or iOS; the handler would + // never be called, so we exit immediately on those platforms. + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let _ = proxy.send_event(UserEvent::Exit); + return; + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let proxy = proxy.clone(); + webview + .screenshot(move |result| { + match result { + Ok(bytes) => { + if let Err(err) = std::fs::write("screenshot.png", bytes) { + eprintln!("failed to write screenshot.png: {err}"); + } else { + println!("wrote screenshot.png"); + } + } + Err(err) => eprintln!("screenshot failed: {err}"), + } + let _ = proxy.send_event(UserEvent::Exit); + }) + .expect("failed to request screenshot"); + } + } + Event::UserEvent(UserEvent::Exit) => *control_flow = ControlFlow::Exit, + _ => {} + } + }); +} diff --git a/src/android/mod.rs b/src/android/mod.rs index 5c7905da4..213bdc9b5 100644 --- a/src/android/mod.rs +++ b/src/android/mod.rs @@ -346,6 +346,14 @@ impl InnerWebView { Ok(()) } + pub fn screenshot(&self, _handler: F) -> Result<()> + where + F: Fn(Result>) + 'static + Send, + { + // Unsupported + Ok(()) + } + pub fn id(&self) -> crate::WebViewId<'_> { &self.id } diff --git a/src/error.rs b/src/error.rs index aed324ee4..3ef96bc2e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -17,6 +17,12 @@ pub enum Error { #[cfg(gtk)] #[error("Couldn't find X11 Display")] X11DisplayNotFound, + #[cfg(gtk)] + #[error(transparent)] + CairoError(#[from] gtk::cairo::Error), + #[cfg(gtk)] + #[error("Failed to convert WebView snapshot to a Pixbuf")] + PixbufConversionFailed, #[cfg(all(gtk, feature = "x11"))] #[error(transparent)] XlibError(#[from] x11_dl::error::OpenError), @@ -74,4 +80,14 @@ pub enum Error { #[cfg(any(target_os = "macos", target_os = "ios"))] #[error("data store is currently opened")] DataStoreInUse, + #[cfg(target_os = "macos")] + #[error("Could not obtain screenshot from webview")] + NilScreenshot, + #[cfg(target_os = "macos")] + #[error("Screenshot failed ({domain}:{code}): {description}")] + MacOsScreenshotError { + domain: String, + code: isize, + description: String, + }, } diff --git a/src/lib.rs b/src/lib.rs index 78d07f05d..502a2d2bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2033,6 +2033,21 @@ impl WebView { self.webview.print() } + /// Capture a PNG screenshot of the currently visible webview contents. + /// + /// The screenshot is returned asynchronously via `handler`. + /// + /// ## Platform-specific + /// + /// - **Linux / macOS / Windows**: Implemented (visible region only). + /// - **Android / iOS**: Not supported; `handler` will never be called. + pub fn screenshot(&self, handler: F) -> Result<()> + where + F: Fn(Result>) + 'static + Send, + { + self.webview.screenshot(handler) + } + /// Get a list of cookies for specific url. pub fn cookies_for_url(&self, url: &str) -> Result>> { self.webview.cookies_for_url(url) diff --git a/src/webkitgtk/mod.rs b/src/webkitgtk/mod.rs index 6be022caf..b983fe5d0 100644 --- a/src/webkitgtk/mod.rs +++ b/src/webkitgtk/mod.rs @@ -37,10 +37,10 @@ use webkit2gtk::WebInspectorExt; use webkit2gtk::{ AutoplayPolicy, CookieManagerExt, InputMethodContextExt, LoadEvent, NavigationPolicyDecision, NavigationPolicyDecisionExt, NetworkProxyMode, NetworkProxySettings, PolicyDecisionType, - PrintOperationExt, SettingsExt, URIRequest, URIRequestExt, UserContentInjectedFrames, - UserContentManager, UserContentManagerExt, UserScript, UserScriptInjectionTime, - WebContextExt as Webkit2gtkWeContextExt, WebView, WebViewExt, WebsiteDataManagerExt, - WebsiteDataManagerExtManual, WebsitePolicies, + PrintOperationExt, SettingsExt, SnapshotOptions, SnapshotRegion, URIRequest, URIRequestExt, + UserContentInjectedFrames, UserContentManager, UserContentManagerExt, UserScript, + UserScriptInjectionTime, WebContextExt as Webkit2gtkWeContextExt, WebView, WebViewExt, + WebsiteDataManagerExt, WebsiteDataManagerExtManual, WebsitePolicies, }; use webkit2gtk_sys::{ webkit_get_major_version, webkit_get_micro_version, webkit_get_minor_version, @@ -679,6 +679,42 @@ impl InnerWebView { Ok(()) } + pub fn screenshot(&self, handler: F) -> Result<()> + where + F: Fn(Result>) + 'static + Send, + { + let cancellable: Option<&Cancellable> = None; + let cb = move |result: std::result::Result| match result + { + Ok(surface) => match gtk::cairo::ImageSurface::try_from(surface) { + Ok(image) => { + let width = image.width(); + let height = image.height(); + match gdk::pixbuf_get_from_surface(&image, 0, 0, width, height) { + Some(pixbuf) => match pixbuf.save_to_bufferv("png", &[]) { + Ok(bytes) => handler(Ok(bytes)), + Err(err) => handler(Err(Error::GlibError(err))), + }, + None => handler(Err(Error::PixbufConversionFailed)), + } + } + Err(_) => handler(Err(Error::CairoError( + gtk::cairo::Error::SurfaceTypeMismatch, + ))), + }, + Err(err) => handler(Err(Error::GlibError(err))), + }; + + self.webview.snapshot( + SnapshotRegion::Visible, + SnapshotOptions::NONE, + cancellable, + cb, + ); + + Ok(()) + } + pub fn url(&self) -> Result { Ok(self.webview.uri().unwrap_or_default().to_string()) } diff --git a/src/webview2/mod.rs b/src/webview2/mod.rs index 2b421a3b3..cd37fe9f9 100644 --- a/src/webview2/mod.rs +++ b/src/webview2/mod.rs @@ -1701,6 +1701,60 @@ impl InnerWebView { ) } + pub fn screenshot(&self, handler: F) -> Result<()> + where + F: Fn(Result>) + 'static + Send, + { + unsafe { + let Some(stream) = SHCreateMemStream(None) else { + return Err(Error::from(windows::core::Error::from(E_POINTER))); + }; + let stream_for_handler = stream.clone(); + + self.webview.CapturePreview( + COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, + &stream, + &CapturePreviewCompletedHandler::create(Box::new(move |res| { + let result = (|| -> windows::core::Result> { + res?; + + let mut bytes = Vec::new(); + stream_for_handler.Seek(0, STREAM_SEEK_SET, None)?; + + let mut buffer = [0u8; 4096]; + loop { + let mut cb_read = 0; + stream_for_handler + .Read( + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + Some(&mut cb_read), + ) + .ok()?; + + if cb_read == 0 { + break; + } + + bytes.extend_from_slice(&buffer[..cb_read as usize]); + } + + Ok(bytes) + })(); + + match result { + Ok(bytes) => handler(Ok(bytes)), + Err(err) => handler(Err(err.into())), + } + + Ok(()) + })), + )?; + } + + Ok(()) + } + pub fn clear_all_browsing_data(&self) -> Result<()> { unsafe { self diff --git a/src/wkwebview/mod.rs b/src/wkwebview/mod.rs index 77f7a4719..254a2164c 100644 --- a/src/wkwebview/mod.rs +++ b/src/wkwebview/mod.rs @@ -38,12 +38,15 @@ use objc2::{ AllocAnyThread, DeclaredClass, MainThreadOnly, Message, }; #[cfg(target_os = "macos")] -use objc2_app_kit::{NSApplication, NSAutoresizingMaskOptions, NSTitlebarSeparatorStyle, NSView}; +use objc2_app_kit::{ + NSApplication, NSAutoresizingMaskOptions, NSBitmapImageFileType, NSBitmapImageRep, + NSBitmapImageRepPropertyKey, NSImage, NSTitlebarSeparatorStyle, NSView, +}; #[cfg(target_os = "macos")] use objc2_core_foundation::CGSize; use objc2_core_foundation::{CGPoint, CGRect}; use objc2_foundation::{ - ns_string, MainThreadMarker, NSArray, NSBundle, NSDate, NSError, NSHTTPCookie, + ns_string, MainThreadMarker, NSArray, NSBundle, NSDate, NSDictionary, NSError, NSHTTPCookie, NSHTTPCookieDomain, NSHTTPCookieExpires, NSHTTPCookieMaximumAge, NSHTTPCookieName, NSHTTPCookiePath, NSHTTPCookiePropertyKey, NSHTTPCookieSecure, NSHTTPCookieValue, NSHTTPCookieVersion, NSJSONSerialization, NSMutableDictionary, NSMutableURLRequest, NSNumber, @@ -69,8 +72,9 @@ use crate::wkwebview::util::operating_system_version; use objc2_web_kit::WKWebView; use objc2_web_kit::{ - WKAudiovisualMediaTypes, WKInactiveSchedulingPolicy, WKURLSchemeHandler, WKUserContentController, - WKUserScript, WKUserScriptInjectionTime, WKWebViewConfiguration, WKWebsiteDataStore, + WKAudiovisualMediaTypes, WKInactiveSchedulingPolicy, WKURLSchemeHandler, + WKUserContentController, WKUserScript, WKUserScriptInjectionTime, WKWebViewConfiguration, + WKWebsiteDataStore, }; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -836,6 +840,63 @@ r#"Object.defineProperty(window, 'ipc', { self.print_with_options(&PrintOptions::default()) } + pub fn screenshot(&self, handler: F) -> crate::Result<()> + where + F: Fn(Result>) + 'static + Send, + { + // Safety: objc runtime calls are unsafe + #[cfg(target_os = "macos")] + unsafe { + let callback = block2::RcBlock::new(move |image: *mut NSImage, err: *mut NSError| { + if let Some(err) = err.as_ref() { + handler(Err(Error::MacOsScreenshotError { + domain: err.domain().to_string(), + code: err.code(), + description: err.localizedDescription().to_string(), + })); + return; + } + + let Some(image) = image.as_ref() else { + handler(Err(Error::NilScreenshot)); + return; + }; + + let Some(cg_image) = image.CGImageForProposedRect_context_hints( + std::ptr::null_mut(), + None, + None, + ) else { + handler(Err(Error::NilScreenshot)); + return; + }; + + let bitmap = NSBitmapImageRep::initWithCGImage(NSBitmapImageRep::alloc(), &cg_image); + bitmap.setSize(image.size()); + + let props: Retained> = + NSDictionary::new(); + let png = bitmap.representationUsingType_properties(NSBitmapImageFileType::PNG, &props); + match png { + Some(data) => handler(Ok(data.to_vec())), + None => handler(Err(Error::NilScreenshot)), + } + }); + + self + .webview + .takeSnapshotWithConfiguration_completionHandler(None, &callback); + } + + #[cfg(target_os = "ios")] + { + // Unsupported + let _ = handler; + } + + Ok(()) + } + pub fn print_with_options(&self, _options: &PrintOptions) -> crate::Result<()> { // Safety: objc runtime calls are unsafe #[cfg(target_os = "macos")]