diff --git a/Cargo.toml b/Cargo.toml index c01ebb6..5bc7c67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ wayland-data-control = ["wl-clipboard-rs"] # For backwards compat core-graphics = ["dep:objc2-core-graphics"] -windows-sys = ["dep:windows-sys"] +windows-sys = ["windows-sys/Win32_Graphics_Gdi"] image = ["dep:image"] wl-clipboard-rs = ["dep:wl-clipboard-rs"] @@ -32,12 +32,13 @@ wl-clipboard-rs = ["dep:wl-clipboard-rs"] env_logger = "0.10.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = ">=0.52.0, <0.60.0", optional = true, features = [ +windows-sys = { version = ">=0.52.0, <0.60.0", features = [ "Win32_Foundation", - "Win32_Graphics_Gdi", + "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", + "Win32_UI_Shell", ] } clipboard-win = { version = "5.3.1", features = ["std"] } log = "0.4" diff --git a/src/lib.rs b/src/lib.rs index 4702827..13eb5ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,10 @@ and conditions of the chosen license apply to this file. #![warn(unreachable_pub)] mod common; -use std::{borrow::Cow, path::PathBuf}; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; pub use common::Error; #[cfg(feature = "image-data")] @@ -243,6 +246,11 @@ impl Set<'_> { pub fn image(self, image: ImageData) -> Result<(), Error> { self.platform.image(image) } + + /// Completes the "set" operation by placing a list of file paths onto the clipboard. + pub fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + self.platform.file_list(file_list) + } } /// A builder for an operation that clears the data from the clipboard. @@ -350,6 +358,19 @@ mod tests { assert_eq!(ctx.get().html().unwrap(), html); } } + { + let mut ctx = Clipboard::new().unwrap(); + + let this_dir = env!("CARGO_MANIFEST_DIR"); + + let paths = &[ + PathBuf::from(this_dir).join("README.md"), + PathBuf::from(this_dir).join("Cargo.toml"), + ]; + + ctx.set().file_list(paths).unwrap(); + assert_eq!(ctx.get().file_list().unwrap().as_slice(), paths); + } #[cfg(feature = "image-data")] { let mut ctx = Clipboard::new().unwrap(); diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index cf47099..b9e4095 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -1,8 +1,13 @@ -use std::{borrow::Cow, path::PathBuf, time::Instant}; +use std::{ + borrow::Cow, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + time::Instant, +}; #[cfg(feature = "wayland-data-control")] use log::{trace, warn}; -use percent_encoding::percent_decode; +use percent_encoding::{percent_decode, percent_encode, AsciiSet, CONTROLS}; #[cfg(feature = "image-data")] use crate::ImageData; @@ -52,6 +57,37 @@ fn paths_from_uri_list(uri_list: Vec) -> Vec { .collect() } +fn paths_to_uri_list(file_list: &[impl AsRef]) -> Result { + // The characters that require encoding, which includes £ and € but they can't be added to the set. + const ASCII_SET: &AsciiSet = &CONTROLS + .add(b'#') + .add(b';') + .add(b'?') + .add(b'[') + .add(b']') + .add(b' ') + .add(b'\"') + .add(b'%') + .add(b'<') + .add(b'>') + .add(b'\\') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + + file_list + .iter() + .filter_map(|path| { + path.as_ref().canonicalize().ok().map(|path| { + format!("file://{}", percent_encode(path.as_os_str().as_bytes(), ASCII_SET)) + }) + }) + .reduce(|uri_list, uri| uri_list + "\n" + &uri) + .ok_or(Error::ConversionFailure) +} + /// Clipboard selection /// /// Linux has a concept of clipboard "selections" which tend to be used in different contexts. This @@ -240,6 +276,25 @@ impl<'clipboard> Set<'clipboard> { } } } + + pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_file_list( + file_list, + self.selection, + self.wait, + self.exclude_from_history, + ), + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_file_list( + file_list, + self.selection, + self.wait, + self.exclude_from_history, + ), + } + } } /// Linux specific extensions to the [`Set`](super::Set) builder. diff --git a/src/platform/linux/wayland.rs b/src/platform/linux/wayland.rs index 860eaee..eb4dfe5 100644 --- a/src/platform/linux/wayland.rs +++ b/src/platform/linux/wayland.rs @@ -1,4 +1,8 @@ -use std::{borrow::Cow, io::Read, path::PathBuf}; +use std::{ + borrow::Cow, + io::Read, + path::{Path, PathBuf}, +}; use wl_clipboard_rs::{ copy::{self, Error as CopyError, MimeSource, MimeType, Options, Source}, @@ -9,8 +13,8 @@ use wl_clipboard_rs::{ #[cfg(feature = "image-data")] use super::encode_as_png; use super::{ - into_unknown, paths_from_uri_list, LinuxClipboardKind, WaitConfig, KDE_EXCLUSION_HINT, - KDE_EXCLUSION_MIME, + into_unknown, paths_from_uri_list, paths_to_uri_list, LinuxClipboardKind, WaitConfig, + KDE_EXCLUSION_HINT, KDE_EXCLUSION_MIME, }; use crate::common::Error; #[cfg(feature = "image-data")] @@ -19,6 +23,8 @@ use crate::common::ImageData; #[cfg(feature = "image-data")] const MIME_PNG: &str = "image/png"; +const MIME_URI: &str = "text/uri-list"; + pub(crate) struct Clipboard {} impl TryInto for LinuxClipboardKind { @@ -228,8 +234,32 @@ impl Clipboard { &mut self, selection: LinuxClipboardKind, ) -> Result, Error> { - handle_clipboard_read(selection, paste::MimeType::Specific("text/uri-list"), |contents| { + handle_clipboard_read(selection, paste::MimeType::Specific(MIME_URI), |contents| { Ok(paths_from_uri_list(contents)) }) } + + pub(crate) fn set_file_list( + &self, + file_list: &[impl AsRef], + selection: LinuxClipboardKind, + wait: WaitConfig, + exclude_from_history: bool, + ) -> Result<(), Error> { + let files = paths_to_uri_list(file_list)?; + + let mut opts = Options::new(); + opts.foreground(matches!(wait, WaitConfig::Forever)); + opts.clipboard(selection.try_into()?); + + let mut sources = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + sources.push(MimeSource { + source: Source::Bytes(files.into_bytes().into_boxed_slice()), + mime_type: MimeType::Specific(String::from(MIME_URI)), + }); + + add_clipboard_exclusions(exclude_from_history, &mut sources); + + opts.copy_multi(sources).map_err(handle_copy_error) + } } diff --git a/src/platform/linux/x11.rs b/src/platform/linux/x11.rs index c920fb9..17f107b 100644 --- a/src/platform/linux/x11.rs +++ b/src/platform/linux/x11.rs @@ -16,7 +16,7 @@ use std::{ borrow::Cow, cell::RefCell, collections::{hash_map::Entry, HashMap}, - path::PathBuf, + path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -46,8 +46,8 @@ use x11rb::{ #[cfg(feature = "image-data")] use super::encode_as_png; use super::{ - into_unknown, paths_from_uri_list, LinuxClipboardKind, WaitConfig, KDE_EXCLUSION_HINT, - KDE_EXCLUSION_MIME, + into_unknown, paths_from_uri_list, paths_to_uri_list, LinuxClipboardKind, WaitConfig, + KDE_EXCLUSION_HINT, KDE_EXCLUSION_MIME, }; #[cfg(feature = "image-data")] use crate::ImageData; @@ -1061,6 +1061,22 @@ impl Clipboard { Ok(paths_from_uri_list(result.bytes)) } + + pub(crate) fn set_file_list( + &self, + file_list: &[impl AsRef], + selection: LinuxClipboardKind, + wait: WaitConfig, + exclude_from_history: bool, + ) -> Result<()> { + let files = paths_to_uri_list(file_list)?; + let mut data = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + + data.push(ClipboardData { bytes: files.into_bytes(), format: self.inner.atoms.URI_LIST }); + self.add_clipboard_exclusions(exclude_from_history, &mut data); + + self.inner.write(data, selection, wait) + } } impl Drop for Clipboard { diff --git a/src/platform/osx.rs b/src/platform/osx.rs index 8637e50..a2591eb 100644 --- a/src/platform/osx.rs +++ b/src/platform/osx.rs @@ -25,7 +25,7 @@ use objc2_foundation::{ns_string, NSArray, NSDictionary, NSNumber, NSString, NSU use std::{ borrow::Cow, panic::{RefUnwindSafe, UnwindSafe}, - path::PathBuf, + path::{Path, PathBuf}, }; /// Returns an NSImage object on success. @@ -352,6 +352,37 @@ impl<'clipboard> Set<'clipboard> { )) } } + + pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + self.clipboard.clear(); + + let uri_list = file_list + .iter() + .filter_map(|path| { + path.as_ref().canonicalize().ok().and_then(|abs_path| { + abs_path.to_str().map(|str| { + let url = unsafe { NSURL::fileURLWithPath(&NSString::from_str(str)) }; + ProtocolObject::from_retained(url) + }) + }) + }) + .collect::>(); + + if uri_list.is_empty() { + return Err(Error::ConversionFailure); + } + + let objects = NSArray::from_retained_slice(&uri_list); + let success = unsafe { self.clipboard.pasteboard.writeObjects(&objects) }; + + add_clipboard_exclusions(self.clipboard, self.exclude_from_history); + + if success { + Ok(()) + } else { + Err(Error::unknown("NSPasteboard#writeObjects: returned false")) + } + } } pub(crate) struct Clear<'clipboard> { diff --git a/src/platform/windows.rs b/src/platform/windows.rs index c8058d9..9880e88 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -11,7 +11,25 @@ and conditions of the chosen license apply to this file. #[cfg(feature = "image-data")] use crate::common::ImageData; use crate::common::{private, Error}; -use std::{borrow::Cow, marker::PhantomData, path::PathBuf, thread, time::Duration}; +use std::{ + borrow::Cow, + io, + marker::PhantomData, + os::windows::{fs::OpenOptionsExt, io::AsRawHandle}, + path::{Path, PathBuf}, + thread, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::{GetLastError, GlobalFree, HANDLE, HGLOBAL, POINT, S_OK}, + Storage::FileSystem::{GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS, VOLUME_NAME_DOS}, + System::{ + DataExchange::SetClipboardData, + Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GHND}, + Ole::CF_HDROP, + }, + UI::Shell::{PathCchStripPrefix, DROPFILES}, +}; #[cfg(feature = "image-data")] mod image_data { @@ -20,38 +38,16 @@ mod image_data { use image::codecs::png::PngEncoder; use image::ExtendedColorType; use image::ImageEncoder; - use std::{convert::TryInto, io, mem::size_of, ptr::copy_nonoverlapping}; + use std::{convert::TryInto, mem::size_of, ptr::copy_nonoverlapping}; use windows_sys::Win32::{ - Foundation::{HANDLE, HGLOBAL}, Graphics::Gdi::{ CreateDIBitmap, DeleteObject, GetDC, GetDIBits, BITMAPINFO, BITMAPINFOHEADER, BITMAPV5HEADER, BI_BITFIELDS, BI_RGB, CBM_INIT, DIB_RGB_COLORS, HBITMAP, HDC, HGDIOBJ, LCS_GM_IMAGES, RGBQUAD, }, - System::{ - DataExchange::SetClipboardData, - Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GHND}, - Ole::CF_DIBV5, - }, + System::Ole::CF_DIBV5, }; - fn last_error(message: &str) -> Error { - let os_error = io::Error::last_os_error(); - Error::unknown(format!("{message}: {os_error}")) - } - - unsafe fn global_unlock_checked(hdata: HGLOBAL) { - // If the memory object is unlocked after decrementing the lock count, the function - // returns zero and GetLastError returns NO_ERROR. If it fails, the return value is - // zero and GetLastError returns a value other than NO_ERROR. - if GlobalUnlock(hdata) == 0 { - let err = io::Error::last_os_error(); - if err.raw_os_error() != Some(0) { - log::error!("Failed calling GlobalUnlock when writing data: {}", err); - } - } - } - pub(super) fn add_cf_dibv5( _open_clipboard: OpenClipboard, image: ImageData, @@ -168,24 +164,6 @@ mod image_data { } } - unsafe fn global_alloc(bytes: usize) -> Result { - let hdata = GlobalAlloc(GHND, bytes); - if hdata.is_null() { - Err(last_error("Could not allocate global memory object")) - } else { - Ok(hdata) - } - } - - unsafe fn global_lock(hmem: HGLOBAL) -> Result<*mut u8, Error> { - let data_ptr = GlobalLock(hmem).cast::(); - if data_ptr.is_null() { - Err(last_error("Could not lock the global memory object")) - } else { - Ok(data_ptr) - } - } - pub(super) fn read_cf_dibv5(dibv5: &[u8]) -> Result, Error> { // The DIBV5 format is a BITMAPV5HEADER followed by the pixel data according to // https://docs.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats @@ -320,32 +298,6 @@ mod image_data { } } - /// An abstraction trait over the different ways a Win32 function may return - /// a value with a failure marker. - /// - /// This is primarily to abstract over changes in `windows-sys` versions and unify how - /// error handling is done in the above image code. - trait ResultValue: Sized { - const NULL: Self; - fn failure(self) -> bool; - } - - // windows-sys >= 0.59 - impl ResultValue for *mut T { - const NULL: Self = core::ptr::null_mut(); - fn failure(self) -> bool { - self == Self::NULL - } - } - - // `windows-sys` 0.52 - impl ResultValue for isize { - const NULL: Self = 0; - fn failure(self) -> bool { - self == Self::NULL - } - } - /// Converts the RGBA (u8) pixel data into the bitmap-native ARGB (u32) /// format in-place. /// @@ -487,6 +439,67 @@ mod image_data { } } +unsafe fn global_alloc(bytes: usize) -> Result { + let hdata = GlobalAlloc(GHND, bytes); + if hdata.is_null() { + Err(last_error("Could not allocate global memory object")) + } else { + Ok(hdata) + } +} + +unsafe fn global_lock(hmem: HGLOBAL) -> Result<*mut u8, Error> { + let data_ptr = GlobalLock(hmem).cast::(); + if data_ptr.is_null() { + Err(last_error("Could not lock the global memory object")) + } else { + Ok(data_ptr) + } +} + +unsafe fn global_unlock_checked(hdata: HGLOBAL) { + // If the memory object is unlocked after decrementing the lock count, the function + // returns zero and GetLastError returns NO_ERROR. If it fails, the return value is + // zero and GetLastError returns a value other than NO_ERROR. + if GlobalUnlock(hdata) == 0 { + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(0) { + log::error!("Failed calling GlobalUnlock when writing data: {}", err); + } + } +} + +fn last_error(message: &str) -> Error { + let os_error = io::Error::last_os_error(); + Error::unknown(format!("{message}: {os_error}")) +} + +/// An abstraction trait over the different ways a Win32 function may return +/// a value with a failure marker. +/// +/// This trait helps unify error handling across varying `windows-sys` versions, +/// providing a consistent interface for representing NULL values. +trait ResultValue: Sized { + const NULL: Self; + fn failure(self) -> bool; +} + +// windows-sys >= 0.59 +impl ResultValue for *mut T { + const NULL: Self = core::ptr::null_mut(); + fn failure(self) -> bool { + self == Self::NULL + } +} + +// `windows-sys` 0.52 +impl ResultValue for isize { + const NULL: Self = 0; + fn failure(self) -> bool { + self == Self::NULL + } +} + /// A shim clipboard type that can have operations performed with it, but /// does not represent an open clipboard itself. /// @@ -517,7 +530,7 @@ impl Clipboard { Ok(Self(())) } - fn open(&mut self) -> Result { + fn open(&mut self) -> Result, Error> { // Attempt to open the clipboard multiple times. On Windows, its common for something else to temporarily // be using it during attempts. // @@ -702,6 +715,74 @@ impl<'clipboard> Set<'clipboard> { image_data::add_cf_dibv5(open_clipboard, image)?; Ok(()) } + + pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + const DROPFILES_HEADER_SIZE: usize = std::mem::size_of::(); + + let clipboard_assertion = self.clipboard?; + + // https://learn.microsoft.com/en-us/windows/win32/shell/clipboard#cf_hdrop + // CF_HDROP consists of an STGMEDIUM structure that contains a global memory object. + // The structure's hGlobal member points to the resulting data: + // | DROPFILES | FILENAME | NULL | ... | nth FILENAME | NULL | NULL | + let dropfiles = DROPFILES { + pFiles: DROPFILES_HEADER_SIZE as u32, + pt: POINT { x: 0, y: 0 }, + fNC: 0, + fWide: 1, + }; + + let mut data_len = DROPFILES_HEADER_SIZE; + + let paths: Vec<_> = file_list + .iter() + .filter_map(|path| { + to_final_path_wide(path.as_ref()).map(|wide| { + // Windows uses wchar_t which is 16 bit + data_len += wide.len() * std::mem::size_of::(); + wide + }) + }) + .collect(); + + if paths.is_empty() { + return Err(Error::ConversionFailure); + } + + // Add space for the final null character + data_len += std::mem::size_of::(); + + unsafe { + let h_global = global_alloc(data_len)?; + let data_ptr = global_lock(h_global)?; + + (data_ptr as *mut DROPFILES).write(dropfiles); + + let mut ptr = data_ptr.add(DROPFILES_HEADER_SIZE) as *mut u16; + + for wide_path in paths { + std::ptr::copy_nonoverlapping::(wide_path.as_ptr(), ptr, wide_path.len()); + ptr = ptr.add(wide_path.len()); + } + + // Write final null character + ptr.write(0); + + global_unlock_checked(h_global); + + if SetClipboardData(CF_HDROP.into(), h_global as HANDLE).failure() { + GlobalFree(h_global); + return Err(last_error("SetClipboardData failed with error")); + } + } + + add_clipboard_exclusions( + clipboard_assertion, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) + } } fn add_clipboard_exclusions( @@ -826,3 +907,102 @@ fn wrap_html(ctn: &str) -> String { "{h_version}{h_start_html}{n_start_html:010}{h_end_html}{n_end_html:010}{h_start_frag}{n_start_frag:010}{h_end_frag}{n_end_frag:010}{c_start_frag}{ctn}{c_end_frag}" ) } + +/// Given a file path attempt to open it and call GetFinalPathNameByHandleW, +/// on success return the final path as a NULL terminated u16 Vec +fn to_final_path_wide(p: &Path) -> Option> { + let file = std::fs::OpenOptions::new() + // No read or write permissions are necessary + .access_mode(0) + // This flag is so we can open directories too + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(p) + .ok()?; + + fill_utf16_buf( + |buf, sz| unsafe { + GetFinalPathNameByHandleW(file.as_raw_handle() as HANDLE, buf, sz, VOLUME_NAME_DOS) + }, + |buf| { + let mut wide = Vec::with_capacity(buf.len() + 1); + wide.extend_from_slice(buf); + wide.push(0); + + let hr = unsafe { PathCchStripPrefix(wide.as_mut_ptr(), wide.len()) }; + // On success truncate invalid data + if hr == S_OK { + if let Some(end) = wide.iter().position(|c| *c == 0) { + // Retain NULL character + wide.truncate(end + 1) + } + } + wide + }, + ) +} + +/// +fn fill_utf16_buf(mut f1: F1, f2: F2) -> Option +where + F1: FnMut(*mut u16, u32) -> u32, + F2: FnOnce(&[u16]) -> T, +{ + // Start off with a stack buf but then spill over to the heap if we end up + // needing more space. + // + // This initial size also works around `GetFullPathNameW` returning + // incorrect size hints for some short paths: + // https://github.com/dylni/normpath/issues/5 + let mut stack_buf: [std::mem::MaybeUninit; 512] = [std::mem::MaybeUninit::uninit(); 512]; + let mut heap_buf: Vec> = Vec::new(); + unsafe { + let mut n = stack_buf.len(); + loop { + let buf = if n <= stack_buf.len() { + &mut stack_buf[..] + } else { + let extra = n - heap_buf.len(); + heap_buf.reserve(extra); + // We used `reserve` and not `reserve_exact`, so in theory we + // may have gotten more than requested. If so, we'd like to use + // it... so long as we won't cause overflow. + n = heap_buf.capacity().min(u32::MAX as usize); + // Safety: MaybeUninit does not need initialization + heap_buf.set_len(n); + &mut heap_buf[..] + }; + + // This function is typically called on windows API functions which + // will return the correct length of the string, but these functions + // also return the `0` on error. In some cases, however, the + // returned "correct length" may actually be 0! + // + // To handle this case we call `SetLastError` to reset it to 0 and + // then check it again if we get the "0 error value". If the "last + // error" is still 0 then we interpret it as a 0 length buffer and + // not an actual error. + windows_sys::Win32::Foundation::SetLastError(0); + let k = match f1(buf.as_mut_ptr().cast::(), n as u32) { + 0 if GetLastError() == 0 => 0, + 0 => return None, + n => n, + } as usize; + if k == n && GetLastError() == windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER + { + n = n.saturating_mul(2).min(u32::MAX as usize); + } else if k > n { + n = k; + } else if k == n { + // It is impossible to reach this point. + // On success, k is the returned string length excluding the null. + // On failure, k is the required buffer length including the null. + // Therefore k never equals n. + unreachable!(); + } else { + // Safety: First `k` values are initialized. + let slice = std::slice::from_raw_parts(buf.as_ptr() as *const u16, k); + return Some(f2(slice)); + } + } + } +}