use core::time::Duration;
use crate::MimeType;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Encoding {
UnicodeText,
Registered(&'static str),
}
#[must_use]
pub const fn encoding_for(mime: MimeType) -> Encoding {
match mime {
MimeType::TextUtf8 => Encoding::UnicodeText,
MimeType::CrcblRon | MimeType::UriList | MimeType::Other(_) => {
Encoding::Registered(mime.as_str())
}
}
}
#[must_use]
pub fn payload_bytes(encoding: Encoding, bytes: &[u8]) -> Vec<u8> {
match encoding {
Encoding::UnicodeText => utf16_nul_bytes(&String::from_utf8_lossy(bytes)),
Encoding::Registered(_) => {
let mut payload = Vec::with_capacity(bytes.len() + 1);
payload.extend_from_slice(bytes);
payload.push(0);
payload
}
}
}
#[must_use]
pub fn utf16_nul_bytes(text: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(text.len() * 2 + 2);
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_ne_bytes());
}
bytes.extend_from_slice(&0u16.to_ne_bytes());
bytes
}
#[must_use]
pub fn utf8_from_utf16_bytes(bytes: &[u8]) -> Vec<u8> {
let mut units = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.as_chunks::<2>().0 {
let unit = u16::from_ne_bytes([pair[0], pair[1]]);
if unit == 0 {
break;
}
units.push(unit);
}
String::from_utf16_lossy(&units).into_bytes()
}
#[must_use]
pub fn trim_trailing_nuls(bytes: &[u8]) -> &[u8] {
let mut end = bytes.len();
while end > 0 && bytes[end - 1] == 0 {
end -= 1;
}
&bytes[..end]
}
pub const DROP_FILES_HEADER: u32 = 20;
#[cfg(target_os = "windows")]
pub const PREFERRED_DROP_EFFECT: &str = "Preferred DropEffect";
#[cfg(target_os = "windows")]
pub const DROP_EFFECT_COPY: u32 = 1;
#[must_use]
pub fn drop_files_block<'a>(paths: impl IntoIterator<Item = &'a str>) -> Vec<u8> {
let mut block = Vec::new();
for field in [DROP_FILES_HEADER, 0, 0, 0, 1] {
block.extend_from_slice(&field.to_le_bytes());
}
for path in paths {
for unit in path.encode_utf16() {
block.extend_from_slice(&unit.to_le_bytes());
}
block.extend_from_slice(&0u16.to_le_bytes());
}
block.extend_from_slice(&0u16.to_le_bytes());
block
}
#[must_use]
pub fn hdrop_paths(uri_list: &[u8]) -> Vec<String> {
crate::clipboard::uri_list_lines(uri_list)
.filter_map(|uri| {
let path = crate::clipboard::windows_uri::file_uri_to_windows_path(uri);
if path.is_none() {
crcbl_core::log::warn!(
"{:?} names no Windows file, so it is left out of the CF_HDROP file list",
String::from_utf8_lossy(uri)
);
}
path
})
.collect()
}
pub const OPEN_ATTEMPTS: u32 = 8;
pub const OPEN_RETRY: Duration = Duration::from_millis(10);
pub const OPEN_BUDGET: Duration = match OPEN_RETRY.checked_mul(OPEN_ATTEMPTS - 1) {
Some(budget) => budget,
None => Duration::MAX,
};
#[cfg(target_os = "windows")]
pub(super) use system::{Clipboard, Opened, format_id};
#[cfg(target_os = "windows")]
mod system {
use core::ptr;
use super::{Encoding, OPEN_ATTEMPTS, OPEN_RETRY, encoding_for};
use crate::MimeType;
use super::super::ffi::{self, Handle, value};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in super::super) enum Opened {
Immediately,
After {
attempts: u32,
},
Refused {
attempts: u32,
error: u32,
},
}
pub(in super::super) struct Clipboard {
opened: Opened,
}
impl Clipboard {
pub(in super::super) fn open(hwnd: Handle) -> Result<Self, Opened> {
let mut error = 0;
for attempt in 1..=OPEN_ATTEMPTS {
if unsafe { ffi::OpenClipboard(hwnd) } != 0 {
let opened = if attempt == 1 {
Opened::Immediately
} else {
Opened::After { attempts: attempt }
};
return Ok(Self { opened });
}
error = unsafe { ffi::GetLastError() };
if attempt < OPEN_ATTEMPTS {
std::thread::sleep(OPEN_RETRY);
}
}
Err(Opened::Refused {
attempts: OPEN_ATTEMPTS,
error,
})
}
pub(in super::super) const fn opened(&self) -> Opened {
self.opened
}
pub(in super::super) fn empty(&self) -> bool {
unsafe { ffi::EmptyClipboard() != 0 }
}
pub(in super::super) fn put(&self, format: u32, bytes: &[u8]) -> bool {
let requested = bytes.len().max(1);
let mem = unsafe { ffi::GlobalAlloc(value::GMEM_MOVEABLE, requested) };
if mem.is_null() {
crcbl_core::log::warn!("GlobalAlloc of {requested} bytes for the clipboard failed");
return false;
}
let allocated = unsafe { ffi::GlobalSize(mem) }.max(requested);
let locked = unsafe { ffi::GlobalLock(mem) };
if locked.is_null() {
unsafe { ffi::GlobalFree(mem) };
crcbl_core::log::warn!("GlobalLock of a clipboard payload failed");
return false;
}
unsafe {
ptr::write_bytes(locked.cast::<u8>(), 0, allocated);
ptr::copy_nonoverlapping(bytes.as_ptr(), locked.cast::<u8>(), bytes.len());
ffi::GlobalUnlock(mem);
}
if unsafe { ffi::SetClipboardData(format, mem) }.is_null() {
let error = unsafe {
ffi::GlobalFree(mem);
ffi::GetLastError()
};
crcbl_core::log::warn!(
"SetClipboardData for format {format} failed with Win32 error {error}"
);
return false;
}
true
}
pub(in super::super) fn get(&self, format: u32) -> Option<Vec<u8>> {
let mem = unsafe { ffi::GetClipboardData(format) };
if mem.is_null() {
return None;
}
let size = unsafe { ffi::GlobalSize(mem) };
if size == 0 {
return Some(Vec::new());
}
let locked = unsafe { ffi::GlobalLock(mem) };
if locked.is_null() {
crcbl_core::log::warn!("GlobalLock of clipboard format {format} failed");
return None;
}
let bytes = unsafe { core::slice::from_raw_parts(locked.cast::<u8>(), size) }.to_vec();
unsafe { ffi::GlobalUnlock(mem) };
Some(bytes)
}
pub(in super::super) fn file_uri_list(&self) -> Option<Vec<u8>> {
let hdrop = unsafe { ffi::GetClipboardData(value::CF_HDROP) };
if hdrop.is_null() {
return None;
}
let paths = unsafe { super::super::dnd::query_files(hdrop) };
let names = paths.iter().filter_map(|path| {
let name = path.to_str();
if name.is_none() {
crcbl_core::log::warn!(
"{path:?} is not valid Unicode, so it has no text/uri-list entry"
);
}
name
});
Some(crate::clipboard::windows_uri::uri_list_from_windows_paths(names).into_bytes())
}
pub(in super::super) fn put_file_list(&self, uri_list: &[u8]) -> bool {
let paths = super::hdrop_paths(uri_list);
if paths.is_empty() {
crcbl_core::log::debug!(
"the text/uri-list names no Windows file, so no CF_HDROP is published"
);
return false;
}
let block = super::drop_files_block(paths.iter().map(String::as_str));
if !self.put(value::CF_HDROP, &block) {
return false;
}
let effect = registered_format(super::PREFERRED_DROP_EFFECT)
.is_some_and(|format| self.put(format, &super::DROP_EFFECT_COPY.to_le_bytes()));
if !effect {
crcbl_core::log::warn!(
"the CF_HDROP was published without a Preferred DropEffect; Explorer will \
choose between copy and move by itself"
);
}
true
}
}
impl Drop for Clipboard {
fn drop(&mut self) {
unsafe { ffi::CloseClipboard() };
}
}
pub(in super::super) fn format_id(mime: MimeType) -> Option<u32> {
match encoding_for(mime) {
Encoding::UnicodeText => Some(value::CF_UNICODETEXT),
Encoding::Registered(name) => registered_format(name),
}
}
fn registered_format(name: &str) -> Option<u32> {
let wide = ffi::wide(name)?;
let id = unsafe { ffi::RegisterClipboardFormatW(wide.as_ptr()) };
if id == 0 {
let error = unsafe { ffi::GetLastError() };
crcbl_core::log::warn!(
"RegisterClipboardFormatW({name}) failed with Win32 error {error}; that format \
cannot be published or read"
);
return None;
}
Some(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_text_is_a_builtin_format_and_every_other_mime_is_registered_by_name() {
assert_eq!(encoding_for(MimeType::TextUtf8), Encoding::UnicodeText);
assert_eq!(
encoding_for(MimeType::CrcblRon),
Encoding::Registered("application/x-crcbl+ron"),
"the spelling is the compatibility contract, not an implementation detail"
);
assert_eq!(
encoding_for(MimeType::UriList),
Encoding::Registered("text/uri-list")
);
assert_eq!(
encoding_for(MimeType::Other("image/png")),
Encoding::Registered("image/png")
);
}
#[test]
fn text_round_trips_through_utf16_including_an_astral_codepoint() {
let text = "a あ 🎮";
let bytes = utf16_nul_bytes(text);
assert_eq!(
bytes.len(),
(text.encode_utf16().count() + 1) * 2,
"one code unit per UTF-16 unit, plus the terminator"
);
assert_eq!(&bytes[bytes.len() - 2..], &[0, 0], "NUL-terminated");
assert_eq!(utf8_from_utf16_bytes(&bytes), text.as_bytes());
assert_eq!(utf16_nul_bytes(""), vec![0, 0]);
assert_eq!(utf8_from_utf16_bytes(&[0, 0]), Vec::<u8>::new());
assert_eq!(utf8_from_utf16_bytes(&[]), Vec::<u8>::new());
}
#[test]
fn a_read_stops_at_the_terminator_and_survives_a_block_without_one() {
let mut padded = utf16_nul_bytes("hi");
padded.extend_from_slice(&[0; 16]);
assert_eq!(utf8_from_utf16_bytes(&padded), b"hi");
let unterminated: Vec<u8> = "hi".encode_utf16().flat_map(u16::to_ne_bytes).collect();
assert_eq!(utf8_from_utf16_bytes(&unterminated), b"hi");
let mut ragged = unterminated.clone();
ragged.push(0x41);
assert_eq!(utf8_from_utf16_bytes(&ragged), b"hi");
let lone = [0x3D, 0xD8, 0x69, 0x00, 0x00, 0x00];
assert_eq!(
utf8_from_utf16_bytes(&lone),
"\u{FFFD}i".as_bytes(),
"one replacement character, and the rest of the string survives"
);
}
#[test]
fn a_registered_payload_is_terminated_on_the_way_out_and_trimmed_on_the_way_back() {
let ron = b"(kind:\"node\",id:7)";
let payload = payload_bytes(Encoding::Registered("application/x-crcbl+ron"), ron);
assert_eq!(payload.last(), Some(&0), "terminated");
assert_eq!(trim_trailing_nuls(&payload), ron);
let mut padded = payload.clone();
padded.extend_from_slice(&[0; 7]);
assert_eq!(trim_trailing_nuls(&padded), ron);
assert_eq!(payload_bytes(Encoding::Registered("x"), b""), vec![0]);
assert_eq!(trim_trailing_nuls(&[0]), b"");
assert_eq!(trim_trailing_nuls(&[]), b"");
assert_eq!(trim_trailing_nuls(b"no nuls here"), b"no nuls here");
}
#[test]
fn a_text_offer_that_is_not_valid_utf8_is_replaced_rather_than_refused() {
let payload = payload_bytes(Encoding::UnicodeText, b"ok\xFFbad");
assert_eq!(utf8_from_utf16_bytes(&payload), "ok\u{FFFD}bad".as_bytes());
}
#[test]
fn a_drop_files_block_is_the_packed_sdk_header_and_a_double_nul_wide_list() {
#[rustfmt::skip]
let expected: &[u8] = &[
20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
b'C', 0, b':', 0, b'\\', 0, b'a', 0, 0, 0,
b'D', 0, b':', 0, b'\\', 0, 0xE9, 0, 0, 0,
0, 0,
];
assert_eq!(drop_files_block([r"C:\a", r"D:\é"]), expected);
use super::super::ffi::DropFiles;
use core::mem::offset_of;
let block = drop_files_block([]);
let field = |at: usize| u32::from_le_bytes(block[at..at + 4].try_into().unwrap());
assert_eq!(DROP_FILES_HEADER as usize, size_of::<DropFiles>());
assert_eq!(field(offset_of!(DropFiles, p_files)), DROP_FILES_HEADER);
assert_eq!(field(offset_of!(DropFiles, f_nc)), 0);
assert_eq!(field(offset_of!(DropFiles, f_wide)), 1, "a wide list");
assert_eq!(
&block[DROP_FILES_HEADER as usize..],
&[0, 0],
"no paths is the terminating NUL alone"
);
}
#[test]
fn only_the_uris_that_name_windows_files_reach_the_file_list() {
let list = b"# a comment\r\n\
file:///C:/a%20b/c.ron\r\n\
https://example.com/x\r\n\
file:///tmp/posix\r\n\
file://server/share/x\n\
\r\n\
file:///C:/a%5Cb\r\n";
assert_eq!(
hdrop_paths(list),
[r"C:\a b\c.ron", r"\\server\share\x"],
"the URL, the drive-less path and the escaped separator are left out; a bare LF \
still ends a line"
);
assert_eq!(
hdrop_paths(b"https://example.com/\r\n# only this\r\n"),
Vec::<String>::new(),
"nothing nameable is an empty list, which the offer publishes no CF_HDROP for"
);
}
#[test]
fn the_open_retry_budget_is_bounded_and_is_a_fraction_of_a_frame() {
assert_eq!(OPEN_BUDGET, OPEN_RETRY * (OPEN_ATTEMPTS - 1));
assert_eq!(OPEN_BUDGET, Duration::from_millis(70));
assert!(
OPEN_BUDGET < Duration::from_millis(100),
"a paste that stalls the pump for longer than this is a stutter the \
user sees: {OPEN_BUDGET:?}"
);
const {
assert!(
OPEN_ATTEMPTS > 1,
"one attempt is not a retry, and a single refusal is routine"
);
}
}
}