diff --git a/src-electron/main-window-ipc.js b/src-electron/main-window-ipc.js index 0a94f49e..3ce627c9 100644 --- a/src-electron/main-window-ipc.js +++ b/src-electron/main-window-ipc.js @@ -370,6 +370,18 @@ function registerWindowIpcHandlers() { win.close(); } }); + + // Capture a region of the current window as PNG + // rect is optional {x, y, width, height} — if omitted, captures the full visible page + ipcMain.handle('capture-page', async (event, rect) => { + assertTrusted(event); + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) { + throw new Error('No window found for capture'); + } + const nativeImage = await event.sender.capturePage(rect); + return nativeImage.toPNG(); + }); } function getWindowLabel(webContentsId) { diff --git a/src-electron/preload.js b/src-electron/preload.js index 0a2a9a64..99467063 100644 --- a/src-electron/preload.js +++ b/src-electron/preload.js @@ -139,6 +139,10 @@ contextBridge.exposeInMainWorld('electronAPI', { setWindowTitle: (title) => ipcRenderer.invoke('set-window-title', title), getWindowTitle: () => ipcRenderer.invoke('get-window-title'), + // Screenshot API — capture a region of the current window as PNG buffer + // rect is optional {x, y, width, height} — if omitted, captures the full visible page + capturePage: (rect) => ipcRenderer.invoke('capture-page', rect), + // Clipboard APIs clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'), clipboardWriteText: (text) => ipcRenderer.invoke('clipboard-write-text', text), diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d0cd12aa..3ec98a5a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3407,12 +3407,14 @@ dependencies = [ [[package]] name = "phoenix-code-ide" -version = "5.1.0" +version = "5.1.2" dependencies = [ "aes-gcm", "backtrace", + "block", "chrono", "clipboard-files", + "cocoa 0.25.0", "dialog", "fix-path-env", "gtk", @@ -3424,6 +3426,7 @@ dependencies = [ "once_cell", "os_info", "percent-encoding", + "png", "regex", "reqwest 0.12.15", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ea3a1f97..ffffac21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,7 +28,8 @@ webbrowser = "1.0" backtrace = "0.3.73" serde = { version = "1.0.200", features = ["derive"] } tauri = { version = "1.6.2", features = [ "updater", "cli", "api-all", "devtools", "linux-protocol-headers"] } -winapi = { version = "0.3.9", features = ["fileapi"] } +winapi = { version = "0.3.9", features = ["fileapi", "wingdi", "winuser", "windef"] } +png = "0.17" tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } @@ -50,6 +51,8 @@ dialog = "0.3.0" [target.'cfg(target_os = "macos")'.dependencies] objc = "0.2.7" +block = "0.1" +cocoa = "0.25" tauri-plugin-deep-link = "0.1.2" lazy_static = "1.4" native-dialog = "0.7.0" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0564025c..c78254bf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,6 +25,9 @@ extern crate webkit2gtk; #[macro_use] extern crate objc; +#[cfg(target_os = "macos")] +extern crate cocoa; + use clipboard_files; #[cfg(target_os = "linux")] @@ -387,6 +390,209 @@ fn get_mac_deep_link_requests() -> Vec { } } +// Screenshot capture types +#[derive(serde::Deserialize)] +struct CaptureRect { + x: f64, + y: f64, + width: f64, + height: f64, +} + +#[tauri::command] +async fn capture_page(window: tauri::Window, rect: Option) -> Result, String> { + #[cfg(target_os = "linux")] + { + let _ = (&window, &rect); + return Err("capture_page is not implemented on Linux".to_string()); + } + + #[cfg(target_os = "macos")] + { + let (tx, rx) = tokio::sync::oneshot::channel::, String>>(); + + let _ = window.with_webview(move |webview| { + unsafe { + let wk_webview = webview.inner(); + + // Create WKSnapshotConfiguration + let config: *mut objc::runtime::Object = msg_send![class!(WKSnapshotConfiguration), new]; + + // Set capture rect if provided + if let Some(r) = &rect { + // CGRect layout: {origin.x, origin.y, size.width, size.height} + #[repr(C)] + struct CGRect { x: f64, y: f64, w: f64, h: f64 } + unsafe impl objc::Encode for CGRect { + fn encode() -> objc::Encoding { + unsafe { objc::Encoding::from_str("{CGRect={CGPoint=dd}{CGSize=dd}}") } + } + } + let cg_rect = CGRect { x: r.x, y: r.y, w: r.width, h: r.height }; + let () = msg_send![config, setRect: cg_rect]; + } + + // Wrap sender in Arc> so the closure is Fn (not FnOnce) + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + + let handler = block::ConcreteBlock::new(move |image: cocoa::base::id, error: cocoa::base::id| { + let mut guard = tx.lock().unwrap(); + let tx = match guard.take() { + Some(tx) => tx, + None => return, + }; + if !image.is_null() { + // NSImage -> TIFF -> NSBitmapImageRep -> PNG + let tiff_data: cocoa::base::id = msg_send![image, TIFFRepresentation]; + if tiff_data.is_null() { + let _ = tx.send(Err("Failed to get TIFF representation".to_string())); + return; + } + let bitmap_rep: cocoa::base::id = msg_send![ + class!(NSBitmapImageRep), imageRepWithData: tiff_data + ]; + if bitmap_rep.is_null() { + let _ = tx.send(Err("Failed to create bitmap representation".to_string())); + return; + } + let empty_dict: cocoa::base::id = msg_send![class!(NSDictionary), dictionary]; + // NSBitmapImageFileTypePNG = 4 + let png_data: cocoa::base::id = msg_send![ + bitmap_rep, representationUsingType: 4usize properties: empty_dict + ]; + if png_data.is_null() { + let _ = tx.send(Err("Failed to create PNG data".to_string())); + return; + } + let length: usize = msg_send![png_data, length]; + let bytes: *const u8 = msg_send![png_data, bytes]; + let vec = std::slice::from_raw_parts(bytes, length).to_vec(); + let _ = tx.send(Ok(vec)); + } else { + let err_msg = if !error.is_null() { + let desc: cocoa::base::id = msg_send![error, localizedDescription]; + let utf8: *const std::os::raw::c_char = msg_send![desc, UTF8String]; + if !utf8.is_null() { + std::ffi::CStr::from_ptr(utf8).to_string_lossy().to_string() + } else { + "Screenshot failed".to_string() + } + } else { + "Screenshot failed with unknown error".to_string() + }; + let _ = tx.send(Err(err_msg)); + } + }); + let handler = handler.copy(); + let completion_handler: &block::Block<(cocoa::base::id, cocoa::base::id), ()> = &handler; + + let () = msg_send![ + wk_webview, + takeSnapshotWithConfiguration: config + completionHandler: completion_handler + ]; + } + }).map_err(|e| e.to_string())?; + + return rx.await.map_err(|_| "Capture channel closed".to_string())?; + } + + #[cfg(windows)] + { + return capture_page_windows(window, rect); + } +} + +#[cfg(windows)] +fn capture_page_windows(window: tauri::Window, rect: Option) -> Result, String> { + use winapi::um::winuser::{GetDC, ReleaseDC, GetClientRect, PrintWindow}; + use winapi::um::wingdi::{ + CreateCompatibleDC, CreateCompatibleBitmap, SelectObject, GetDIBits, + DeleteDC, DeleteObject, BITMAPINFOHEADER, BITMAPINFO, BI_RGB, DIB_RGB_COLORS, + }; + use winapi::shared::windef::{RECT, HGDIOBJ}; + + unsafe { + let hwnd = window.hwnd().map_err(|e| e.to_string())?; + let hwnd = hwnd.0 as winapi::shared::windef::HWND; + + let mut client_rect: RECT = std::mem::zeroed(); + GetClientRect(hwnd, &mut client_rect); + let full_width = client_rect.right - client_rect.left; + let full_height = client_rect.bottom - client_rect.top; + + if full_width <= 0 || full_height <= 0 { + return Err("Window has zero client area".to_string()); + } + + let hdc_screen = GetDC(hwnd); + let hdc_mem = CreateCompatibleDC(hdc_screen); + let hbitmap = CreateCompatibleBitmap(hdc_screen, full_width, full_height); + let old_bitmap = SelectObject(hdc_mem, hbitmap as HGDIOBJ); + + // PW_CLIENTONLY=1 | PW_RENDERFULLCONTENT=2 (captures HW-accelerated content) + PrintWindow(hwnd, hdc_mem, 1 | 2); + + let mut bmi: BITMAPINFO = std::mem::zeroed(); + bmi.bmiHeader.biSize = std::mem::size_of::() as u32; + bmi.bmiHeader.biWidth = full_width; + bmi.bmiHeader.biHeight = -full_height; // negative = top-down + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + let mut pixels = vec![0u8; (full_width * full_height * 4) as usize]; + GetDIBits( + hdc_mem, hbitmap, 0, full_height as u32, + pixels.as_mut_ptr() as *mut _, + &mut bmi, DIB_RGB_COLORS, + ); + + SelectObject(hdc_mem, old_bitmap); + DeleteObject(hbitmap as HGDIOBJ); + DeleteDC(hdc_mem); + ReleaseDC(hwnd, hdc_screen); + + // BGRA -> RGBA + for chunk in pixels.chunks_exact_mut(4) { + chunk.swap(0, 2); + } + + // Extract the requested region (or full image) + let (cap_x, cap_y, cap_w, cap_h) = if let Some(r) = &rect { + let x = (r.x as i32).max(0).min(full_width); + let y = (r.y as i32).max(0).min(full_height); + let w = (r.width as i32).min(full_width - x).max(0); + let h = (r.height as i32).min(full_height - y).max(0); + (x, y, w, h) + } else { + (0, 0, full_width, full_height) + }; + + if cap_w <= 0 || cap_h <= 0 { + return Err("Capture region is empty".to_string()); + } + + let mut region_pixels = Vec::with_capacity((cap_w * cap_h * 4) as usize); + for y in cap_y..(cap_y + cap_h) { + let start = ((y * full_width + cap_x) * 4) as usize; + let end = start + (cap_w * 4) as usize; + region_pixels.extend_from_slice(&pixels[start..end]); + } + + // Encode as PNG + let mut png_bytes: Vec = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png_bytes, cap_w as u32, cap_h as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().map_err(|e| e.to_string())?; + writer.write_image_data(®ion_pixels).map_err(|e| e.to_string())?; + } + Ok(png_bytes) + } +} + const PHOENIX_CRED_PREFIX: &str = "phcode_"; fn get_username() -> String { @@ -627,7 +833,7 @@ fn main() { put_item, get_item, get_all_items, delete_item, trust_window_aes_key, remove_trust_window_aes_key, _get_windows_drives, _rename_path, show_in_folder, move_to_trash, zoom_window, - _get_clipboard_files, _open_url_in_browser_win]) + _get_clipboard_files, _open_url_in_browser_win, capture_page]) .setup(|app| { init::init_app(app); #[cfg(target_os = "linux")]