From 4354424a0fdab6c0252baa9074922126865319c0 Mon Sep 17 00:00:00 2001 From: Gae24 <96017547+Gae24@users.noreply.github.com> Date: Tue, 18 Mar 2025 12:56:22 +0100 Subject: [PATCH 1/7] add file_list to Set interface --- src/lib.rs | 5 ++++ src/platform/linux/mod.rs | 54 +++++++++++++++++++++++++++++++++-- src/platform/linux/wayland.rs | 29 +++++++++++++++++-- src/platform/linux/x11.rs | 19 ++++++++++-- src/platform/osx.rs | 33 ++++++++++++++++++++- src/platform/windows.rs | 7 +++++ 6 files changed, 138 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 47028273..f8696d54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -243,6 +243,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: Vec) -> Result<(), Error> { + self.platform.file_list(&file_list) + } } /// A builder for an operation that clears the data from the clipboard. diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index cf470990..4b0fd7e1 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -1,8 +1,12 @@ -use std::{borrow::Cow, path::PathBuf, time::Instant}; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, + time::Instant, +}; #[cfg(feature = "wayland-data-control")] use log::{trace, warn}; -use percent_encoding::percent_decode; +use percent_encoding::{percent_decode, utf8_percent_encode, AsciiSet, CONTROLS}; #[cfg(feature = "image-data")] use crate::ImageData; @@ -52,6 +56,39 @@ 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().and_then(|abs_path| { + abs_path + .to_str() + .map(|str| format!("file://{}", utf8_percent_encode(str, 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 +277,19 @@ 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) + } + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => { + clipboard.set_file_list(file_list, self.selection, self.wait) + } + } + } } /// Linux specific extensions to the [`Set`](super::Set) builder. diff --git a/src/platform/linux/wayland.rs b/src/platform/linux/wayland.rs index 860eaee6..08539f4c 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")] @@ -232,4 +236,23 @@ impl Clipboard { Ok(paths_from_uri_list(contents)) }) } + + pub(crate) fn set_file_list( + &self, + file_list: &[impl AsRef], + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<(), Error> { + let files = paths_to_uri_list(file_list)?; + let uri_mime = MimeType::Specific(String::from("text/uri-list")); + let mut opts = Options::new(); + opts.foreground(matches!(wait, WaitConfig::Forever)); + opts.clipboard(selection.try_into()?); + let source = Source::Bytes(files.into_bytes().into_boxed_slice()); + opts.copy(source, uri_mime).map_err(|e| match e { + CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, + other => into_unknown(other), + })?; + Ok(()) + } } diff --git a/src/platform/linux/x11.rs b/src/platform/linux/x11.rs index c920fb98..78aedc67 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,19 @@ impl Clipboard { Ok(paths_from_uri_list(result.bytes)) } + + pub(crate) fn set_file_list( + &self, + file_list: &[impl AsRef], + selection: LinuxClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let files = paths_to_uri_list(file_list)?; + + let data = + vec![ClipboardData { bytes: files.into_bytes(), format: self.inner.atoms.URI_LIST }]; + self.inner.write(data, selection, wait) + } } impl Drop for Clipboard { diff --git a/src/platform/osx.rs b/src/platform/osx.rs index 8637e503..a2591ebd 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 c8058d9e..0bad7319 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -702,6 +702,13 @@ 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> { + let _clipboard_assertion = self.clipboard?; + + clipboard_win::raw::set_file_list(file_list) + .map_err(|e| Error::unknown(format!("Setting file list failed with error code: {e}"))) + } } fn add_clipboard_exclusions( From 0397a79aa2a6f89acd39366f72009ccd888ec5a9 Mon Sep 17 00:00:00 2001 From: Gae24 Date: Sat, 5 Apr 2025 19:52:22 +0200 Subject: [PATCH 2/7] windows: rewrite Set's file_list --- Cargo.toml | 7 +- src/lib.rs | 22 ++++- src/platform/windows.rs | 186 ++++++++++++++++++++++++++++------------ 3 files changed, 156 insertions(+), 59 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c01ebb66..fd2dbb54 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_Globalization", "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 f8696d54..13eb5ff1 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")] @@ -245,8 +248,8 @@ impl Set<'_> { } /// Completes the "set" operation by placing a list of file paths onto the clipboard. - pub fn file_list(self, file_list: Vec) -> Result<(), Error> { - self.platform.file_list(&file_list) + pub fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + self.platform.file_list(file_list) } } @@ -355,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/windows.rs b/src/platform/windows.rs index 0bad7319..8465b7e8 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, + mem::size_of, + path::{Path, PathBuf}, + thread, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::{GlobalFree, HANDLE, HGLOBAL, POINT}, + Globalization::{MultiByteToWideChar, CP_UTF8}, + System::{ + DataExchange::SetClipboardData, + Memory::{GlobalAlloc, GlobalLock, GHND}, + Ole::CF_HDROP, + }, + UI::Shell::DROPFILES, +}; #[cfg(feature = "image-data")] mod image_data { @@ -28,11 +46,7 @@ mod image_data { 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::{DataExchange::SetClipboardData, Memory::GlobalUnlock, Ole::CF_DIBV5}, }; fn last_error(message: &str) -> Error { @@ -168,24 +182,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 +316,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 +457,55 @@ 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) + } +} + +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 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 + } +} + /// A shim clipboard type that can have operations performed with it, but /// does not represent an open clipboard itself. /// @@ -703,11 +722,72 @@ impl<'clipboard> Set<'clipboard> { Ok(()) } - pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + const DROPFILES_HEADER_SIZE: usize = size_of::(); + let _clipboard_assertion = self.clipboard?; - clipboard_win::raw::set_file_list(file_list) - .map_err(|e| Error::unknown(format!("Setting file list failed with error code: {e}"))) + // 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 str_paths: Vec = file_list + .iter() + .filter_map(|path| { + path.as_ref().canonicalize().ok().and_then(|abs_path| { + abs_path.to_str().map(|str| { + // Windows uses wchar_t which is 16 bit + data_len += (str.len() + 1) * size_of::(); + str.to_owned() + }) + }) + }) + .collect(); + + if str_paths.is_empty() { + return Err(Error::ConversionFailure); + } + + data_len += 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; + let mut offset = data_len as i32; + + for str in str_paths { + let written = + MultiByteToWideChar(CP_UTF8, 0, str.as_ptr(), str.len() as i32, ptr, offset); + ptr = ptr.offset(written as isize); + // Write null character + ptr.write(0); + ptr = ptr.add(1); + offset -= written - 1; + } + + // Write final null character + ptr.write(0); + + if SetClipboardData(CF_HDROP.into(), h_global as HANDLE).failure() { + GlobalFree(h_global); + Err(last_error("SetClipboardData failed with error")) + } else { + Ok(()) + } + } } } From 92f654ac7dc0f550298aa1f9fde973ac1c8005ec Mon Sep 17 00:00:00 2001 From: Gae24 Date: Mon, 28 Jul 2025 12:56:24 +0200 Subject: [PATCH 3/7] Windows: fix Set's file_list use PathCchStripPrefix to strip UNC prefix --- Cargo.toml | 2 +- src/platform/windows.rs | 151 +++++++++++++++++++++++++++++++--------- 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fd2dbb54..5bc7c679 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ env_logger = "0.10.2" [target.'cfg(windows)'.dependencies] windows-sys = { version = ">=0.52.0, <0.60.0", features = [ "Win32_Foundation", - "Win32_Globalization", + "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 8465b7e8..90246992 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -15,20 +15,20 @@ use std::{ borrow::Cow, io, marker::PhantomData, - mem::size_of, + os::windows::{fs::OpenOptionsExt, io::AsRawHandle}, path::{Path, PathBuf}, thread, time::Duration, }; use windows_sys::Win32::{ - Foundation::{GlobalFree, HANDLE, HGLOBAL, POINT}, - Globalization::{MultiByteToWideChar, CP_UTF8}, + Foundation::{GetLastError, GlobalFree, HANDLE, HGLOBAL, POINT, S_OK}, + Storage::FileSystem::{GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS, VOLUME_NAME_DOS}, System::{ DataExchange::SetClipboardData, Memory::{GlobalAlloc, GlobalLock, GHND}, Ole::CF_HDROP, }, - UI::Shell::DROPFILES, + UI::Shell::{PathCchStripPrefix, DROPFILES}, }; #[cfg(feature = "image-data")] @@ -40,20 +40,14 @@ mod image_data { use image::ImageEncoder; use std::{convert::TryInto, io, 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::GlobalUnlock, Ole::CF_DIBV5}, + System::{Memory::GlobalUnlock, 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 @@ -477,7 +471,7 @@ unsafe fn global_lock(hmem: HGLOBAL) -> Result<*mut u8, Error> { fn last_error(message: &str) -> Error { let os_error = io::Error::last_os_error(); - Error::unknown(format!("{}: {}", message, os_error)) + Error::unknown(format!("{message}: {os_error}")) } /// An abstraction trait over the different ways a Win32 function may return @@ -723,7 +717,7 @@ impl<'clipboard> Set<'clipboard> { } pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { - const DROPFILES_HEADER_SIZE: usize = size_of::(); + const DROPFILES_HEADER_SIZE: usize = std::mem::size_of::(); let _clipboard_assertion = self.clipboard?; @@ -740,24 +734,23 @@ impl<'clipboard> Set<'clipboard> { let mut data_len = DROPFILES_HEADER_SIZE; - let str_paths: Vec = file_list + let paths: Vec<_> = file_list .iter() .filter_map(|path| { - path.as_ref().canonicalize().ok().and_then(|abs_path| { - abs_path.to_str().map(|str| { - // Windows uses wchar_t which is 16 bit - data_len += (str.len() + 1) * size_of::(); - str.to_owned() - }) + 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 str_paths.is_empty() { + if paths.is_empty() { return Err(Error::ConversionFailure); } - data_len += size_of::(); + // Add space for the final null character + data_len += std::mem::size_of::(); unsafe { let h_global = global_alloc(data_len)?; @@ -766,16 +759,12 @@ impl<'clipboard> Set<'clipboard> { (data_ptr as *mut DROPFILES).write(dropfiles); let mut ptr = data_ptr.add(DROPFILES_HEADER_SIZE) as *mut u16; - let mut offset = data_len as i32; - - for str in str_paths { - let written = - MultiByteToWideChar(CP_UTF8, 0, str.as_ptr(), str.len() as i32, ptr, offset); - ptr = ptr.offset(written as isize); - // Write null character - ptr.write(0); - ptr = ptr.add(1); - offset -= written - 1; + + for wide_path in paths { + for wchar in wide_path { + ptr.write(wchar); + ptr = ptr.add(1); + } } // Write final null character @@ -913,3 +902,101 @@ 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)); + } + } + } +} From f90ff54936bfae80b45dfe657096b6d34cf8cbc7 Mon Sep 17 00:00:00 2001 From: Gae24 <96017547+Gae24@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:53:03 +0200 Subject: [PATCH 4/7] Linux: address feedback --- src/platform/linux/mod.rs | 27 ++++++++++++++++----------- src/platform/linux/wayland.rs | 23 +++++++++++++++-------- src/platform/linux/x11.rs | 7 +++++-- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index 4b0fd7e1..b9e4095a 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -1,12 +1,13 @@ 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, utf8_percent_encode, AsciiSet, CONTROLS}; +use percent_encoding::{percent_decode, percent_encode, AsciiSet, CONTROLS}; #[cfg(feature = "image-data")] use crate::ImageData; @@ -79,10 +80,8 @@ fn paths_to_uri_list(file_list: &[impl AsRef]) -> Result { file_list .iter() .filter_map(|path| { - path.as_ref().canonicalize().ok().and_then(|abs_path| { - abs_path - .to_str() - .map(|str| format!("file://{}", utf8_percent_encode(str, ASCII_SET))) + 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) @@ -280,14 +279,20 @@ 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) - } + 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) - } + Clipboard::WlDataControl(clipboard) => clipboard.set_file_list( + file_list, + self.selection, + self.wait, + self.exclude_from_history, + ), } } } diff --git a/src/platform/linux/wayland.rs b/src/platform/linux/wayland.rs index 08539f4c..eb4dfe5d 100644 --- a/src/platform/linux/wayland.rs +++ b/src/platform/linux/wayland.rs @@ -23,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 { @@ -232,7 +234,7 @@ 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)) }) } @@ -242,17 +244,22 @@ impl Clipboard { file_list: &[impl AsRef], selection: LinuxClipboardKind, wait: WaitConfig, + exclude_from_history: bool, ) -> Result<(), Error> { let files = paths_to_uri_list(file_list)?; - let uri_mime = MimeType::Specific(String::from("text/uri-list")); + let mut opts = Options::new(); opts.foreground(matches!(wait, WaitConfig::Forever)); opts.clipboard(selection.try_into()?); - let source = Source::Bytes(files.into_bytes().into_boxed_slice()); - opts.copy(source, uri_mime).map_err(|e| match e { - CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, - other => into_unknown(other), - })?; - Ok(()) + + 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 78aedc67..17f107b2 100644 --- a/src/platform/linux/x11.rs +++ b/src/platform/linux/x11.rs @@ -1067,11 +1067,14 @@ impl Clipboard { 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); - let data = - vec![ClipboardData { bytes: files.into_bytes(), format: self.inner.atoms.URI_LIST }]; self.inner.write(data, selection, wait) } } From 1a8e84b3fa2708e6dd86eaa219a2e71ded5b4276 Mon Sep 17 00:00:00 2001 From: Gae24 Date: Tue, 5 Aug 2025 10:21:57 +0200 Subject: [PATCH 5/7] Windows: address feedback --- src/platform/windows.rs | 43 +++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 90246992..88a07d5c 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -25,7 +25,7 @@ use windows_sys::Win32::{ Storage::FileSystem::{GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS, VOLUME_NAME_DOS}, System::{ DataExchange::SetClipboardData, - Memory::{GlobalAlloc, GlobalLock, GHND}, + Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GHND}, Ole::CF_HDROP, }, UI::Shell::{PathCchStripPrefix, DROPFILES}, @@ -38,28 +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::{ 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::{Memory::GlobalUnlock, Ole::CF_DIBV5}, + System::Ole::CF_DIBV5, }; - 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, @@ -469,6 +457,18 @@ unsafe fn global_lock(hmem: HGLOBAL) -> Result<*mut u8, 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); + } + } +} + fn last_error(message: &str) -> Error { let os_error = io::Error::last_os_error(); Error::unknown(format!("{message}: {os_error}")) @@ -477,8 +477,8 @@ fn last_error(message: &str) -> Error { /// 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. +/// 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; @@ -761,15 +761,15 @@ impl<'clipboard> Set<'clipboard> { let mut ptr = data_ptr.add(DROPFILES_HEADER_SIZE) as *mut u16; for wide_path in paths { - for wchar in wide_path { - ptr.write(wchar); - ptr = ptr.add(1); - } + 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); Err(last_error("SetClipboardData failed with error")) @@ -936,6 +936,7 @@ fn to_final_path_wide(p: &Path) -> Option> { ) } +/// fn fill_utf16_buf(mut f1: F1, f2: F2) -> Option where F1: FnMut(*mut u16, u32) -> u32, From 47036f82b2696d5f028009daa9bb7fe634173a34 Mon Sep 17 00:00:00 2001 From: Gae24 <96017547+Gae24@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:52:17 +0200 Subject: [PATCH 6/7] Windows: use add_clipboard_exclusions inside Set's file_list --- src/platform/windows.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 88a07d5c..34ae55a8 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -719,7 +719,7 @@ impl<'clipboard> Set<'clipboard> { 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?; + 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. @@ -772,11 +772,16 @@ impl<'clipboard> Set<'clipboard> { if SetClipboardData(CF_HDROP.into(), h_global as HANDLE).failure() { GlobalFree(h_global); - Err(last_error("SetClipboardData failed with error")) - } else { - Ok(()) + 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, + ) } } From 3294a3a19edae11717446c48dd440e544a30c043 Mon Sep 17 00:00:00 2001 From: Gae24 <96017547+Gae24@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:09:46 +0200 Subject: [PATCH 7/7] Windows: fix clippy --- src/platform/windows.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 34ae55a8..9880e885 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -530,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. //