clipboard.rs

  1use std::sync::LazyLock;
  2
  3use anyhow::Result;
  4use collections::{FxHashMap, FxHashSet};
  5use itertools::Itertools;
  6use windows::Win32::{
  7    Foundation::{HANDLE, HGLOBAL},
  8    System::{
  9        DataExchange::{
 10            CloseClipboard, CountClipboardFormats, EmptyClipboard, EnumClipboardFormats,
 11            GetClipboardData, GetClipboardFormatNameW, IsClipboardFormatAvailable, OpenClipboard,
 12            RegisterClipboardFormatW, SetClipboardData,
 13        },
 14        Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock},
 15        Ole::{CF_HDROP, CF_UNICODETEXT},
 16    },
 17    UI::Shell::{DragQueryFileW, HDROP},
 18};
 19use windows_core::PCWSTR;
 20
 21use crate::{ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, hash};
 22
 23// https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-dragqueryfilew
 24const DRAGDROP_GET_FILES_COUNT: u32 = 0xFFFFFFFF;
 25
 26// Clipboard formats
 27static CLIPBOARD_HASH_FORMAT: LazyLock<u32> =
 28    LazyLock::new(|| register_clipboard_format(windows::core::w!("GPUI internal text hash")));
 29static CLIPBOARD_METADATA_FORMAT: LazyLock<u32> =
 30    LazyLock::new(|| register_clipboard_format(windows::core::w!("GPUI internal metadata")));
 31static CLIPBOARD_SVG_FORMAT: LazyLock<u32> =
 32    LazyLock::new(|| register_clipboard_format(windows::core::w!("image/svg+xml")));
 33static CLIPBOARD_GIF_FORMAT: LazyLock<u32> =
 34    LazyLock::new(|| register_clipboard_format(windows::core::w!("GIF")));
 35static CLIPBOARD_PNG_FORMAT: LazyLock<u32> =
 36    LazyLock::new(|| register_clipboard_format(windows::core::w!("PNG")));
 37static CLIPBOARD_JPG_FORMAT: LazyLock<u32> =
 38    LazyLock::new(|| register_clipboard_format(windows::core::w!("JFIF")));
 39
 40// Helper maps and sets
 41static FORMATS_MAP: LazyLock<FxHashMap<u32, ClipboardFormatType>> = LazyLock::new(|| {
 42    let mut formats_map = FxHashMap::default();
 43    formats_map.insert(CF_UNICODETEXT.0 as u32, ClipboardFormatType::Text);
 44    formats_map.insert(*CLIPBOARD_PNG_FORMAT, ClipboardFormatType::Image);
 45    formats_map.insert(*CLIPBOARD_GIF_FORMAT, ClipboardFormatType::Image);
 46    formats_map.insert(*CLIPBOARD_JPG_FORMAT, ClipboardFormatType::Image);
 47    formats_map.insert(*CLIPBOARD_SVG_FORMAT, ClipboardFormatType::Image);
 48    formats_map.insert(CF_HDROP.0 as u32, ClipboardFormatType::Files);
 49    formats_map
 50});
 51static FORMATS_SET: LazyLock<FxHashSet<u32>> = LazyLock::new(|| {
 52    let mut formats_map = FxHashSet::default();
 53    formats_map.insert(CF_UNICODETEXT.0 as u32);
 54    formats_map.insert(*CLIPBOARD_PNG_FORMAT);
 55    formats_map.insert(*CLIPBOARD_GIF_FORMAT);
 56    formats_map.insert(*CLIPBOARD_JPG_FORMAT);
 57    formats_map.insert(*CLIPBOARD_SVG_FORMAT);
 58    formats_map.insert(CF_HDROP.0 as u32);
 59    formats_map
 60});
 61static IMAGE_FORMATS_MAP: LazyLock<FxHashMap<u32, ImageFormat>> = LazyLock::new(|| {
 62    let mut formats_map = FxHashMap::default();
 63    formats_map.insert(*CLIPBOARD_PNG_FORMAT, ImageFormat::Png);
 64    formats_map.insert(*CLIPBOARD_GIF_FORMAT, ImageFormat::Gif);
 65    formats_map.insert(*CLIPBOARD_JPG_FORMAT, ImageFormat::Jpeg);
 66    formats_map.insert(*CLIPBOARD_SVG_FORMAT, ImageFormat::Svg);
 67    formats_map
 68});
 69
 70#[derive(Debug, Clone, Copy)]
 71enum ClipboardFormatType {
 72    Text,
 73    Image,
 74    Files,
 75}
 76
 77pub(crate) fn write_to_clipboard(item: ClipboardItem) {
 78    with_clipboard(|| write_to_clipboard_inner(item));
 79}
 80
 81pub(crate) fn read_from_clipboard() -> Option<ClipboardItem> {
 82    with_clipboard(|| {
 83        with_best_match_format(|item_format| match format_to_type(item_format) {
 84            ClipboardFormatType::Text => read_string_from_clipboard(),
 85            ClipboardFormatType::Image => read_image_from_clipboard(item_format),
 86            ClipboardFormatType::Files => read_files_from_clipboard(),
 87        })
 88    })
 89    .flatten()
 90}
 91
 92pub(crate) fn with_file_names<F>(hdrop: HDROP, mut f: F)
 93where
 94    F: FnMut(String),
 95{
 96    let file_count = unsafe { DragQueryFileW(hdrop, DRAGDROP_GET_FILES_COUNT, None) };
 97    for file_index in 0..file_count {
 98        let filename_length = unsafe { DragQueryFileW(hdrop, file_index, None) } as usize;
 99        let mut buffer = vec![0u16; filename_length + 1];
100        let ret = unsafe { DragQueryFileW(hdrop, file_index, Some(buffer.as_mut_slice())) };
101        if ret == 0 {
102            log::error!("unable to read file name of dragged file");
103            continue;
104        }
105        match String::from_utf16(&buffer[0..filename_length]) {
106            Ok(file_name) => f(file_name),
107            Err(e) => {
108                log::error!("dragged file name is not UTF-16: {}", e)
109            }
110        }
111    }
112}
113
114fn with_clipboard<F, T>(f: F) -> Option<T>
115where
116    F: FnOnce() -> T,
117{
118    match unsafe { OpenClipboard(None) } {
119        Ok(()) => {
120            let result = f();
121            if let Err(e) = unsafe { CloseClipboard() } {
122                log::error!("Failed to close clipboard: {e}",);
123            }
124            Some(result)
125        }
126        Err(e) => {
127            log::error!("Failed to open clipboard: {e}",);
128            None
129        }
130    }
131}
132
133fn register_clipboard_format(format: PCWSTR) -> u32 {
134    let ret = unsafe { RegisterClipboardFormatW(format) };
135    if ret == 0 {
136        panic!(
137            "Error when registering clipboard format: {}",
138            std::io::Error::last_os_error()
139        );
140    }
141    ret
142}
143
144#[inline]
145fn format_to_type(item_format: u32) -> &'static ClipboardFormatType {
146    FORMATS_MAP.get(&item_format).unwrap()
147}
148
149// Currently, we only write the first item.
150fn write_to_clipboard_inner(item: ClipboardItem) -> Result<()> {
151    unsafe {
152        EmptyClipboard()?;
153    }
154    match item.entries().first() {
155        Some(entry) => match entry {
156            ClipboardEntry::String(string) => {
157                write_string_to_clipboard(string)?;
158            }
159            ClipboardEntry::Image(image) => {
160                write_image_to_clipboard(image)?;
161            }
162        },
163        None => {
164            // Writing an empty list of entries just clears the clipboard.
165        }
166    }
167    Ok(())
168}
169
170fn write_string_to_clipboard(item: &ClipboardString) -> Result<()> {
171    let encode_wide = item.text.encode_utf16().chain(Some(0)).collect_vec();
172    set_data_to_clipboard(&encode_wide, CF_UNICODETEXT.0 as u32)?;
173
174    if let Some(metadata) = item.metadata.as_ref() {
175        let hash_result = {
176            let hash = ClipboardString::text_hash(&item.text);
177            hash.to_ne_bytes()
178        };
179        let encode_wide =
180            unsafe { std::slice::from_raw_parts(hash_result.as_ptr().cast::<u16>(), 4) };
181        set_data_to_clipboard(encode_wide, *CLIPBOARD_HASH_FORMAT)?;
182
183        let metadata_wide = metadata.encode_utf16().chain(Some(0)).collect_vec();
184        set_data_to_clipboard(&metadata_wide, *CLIPBOARD_METADATA_FORMAT)?;
185    }
186    Ok(())
187}
188
189fn set_data_to_clipboard<T>(data: &[T], format: u32) -> Result<()> {
190    unsafe {
191        let global = GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of_val(data))?;
192        let handle = GlobalLock(global);
193        std::ptr::copy_nonoverlapping(data.as_ptr(), handle as _, data.len());
194        let _ = GlobalUnlock(global);
195        SetClipboardData(format, Some(HANDLE(global.0)))?;
196    }
197    Ok(())
198}
199
200// Here writing PNG to the clipboard to better support other apps. For more info, please ref to
201// the PR.
202fn write_image_to_clipboard(item: &Image) -> Result<()> {
203    match item.format {
204        ImageFormat::Svg => set_data_to_clipboard(item.bytes(), *CLIPBOARD_SVG_FORMAT)?,
205        ImageFormat::Gif => {
206            set_data_to_clipboard(item.bytes(), *CLIPBOARD_GIF_FORMAT)?;
207            let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Gif)?;
208            set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?;
209        }
210        ImageFormat::Png => {
211            set_data_to_clipboard(item.bytes(), *CLIPBOARD_PNG_FORMAT)?;
212            let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Png)?;
213            set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?;
214        }
215        ImageFormat::Jpeg => {
216            set_data_to_clipboard(item.bytes(), *CLIPBOARD_JPG_FORMAT)?;
217            let png_bytes = convert_image_to_png_format(item.bytes(), ImageFormat::Jpeg)?;
218            set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?;
219        }
220        other => {
221            log::warn!(
222                "Clipboard unsupported image format: {:?}, convert to PNG instead.",
223                item.format
224            );
225            let png_bytes = convert_image_to_png_format(item.bytes(), other)?;
226            set_data_to_clipboard(&png_bytes, *CLIPBOARD_PNG_FORMAT)?;
227        }
228    }
229    Ok(())
230}
231
232fn convert_image_to_png_format(bytes: &[u8], image_format: ImageFormat) -> Result<Vec<u8>> {
233    let image = image::load_from_memory_with_format(bytes, image_format.into())?;
234    let mut output_buf = Vec::new();
235    image.write_to(
236        &mut std::io::Cursor::new(&mut output_buf),
237        image::ImageFormat::Png,
238    )?;
239    Ok(output_buf)
240}
241
242// Here, we enumerate all formats on the clipboard and find the first one that we can process.
243// The reason we don't use `GetPriorityClipboardFormat` is that it sometimes returns the
244// wrong format.
245// For instance, when copying a JPEG image from  Microsoft Word, there may be several formats
246// on the clipboard: Jpeg, Png, Svg.
247// If we use `GetPriorityClipboardFormat`, it will return Svg, which is not what we want.
248fn with_best_match_format<F>(f: F) -> Option<ClipboardItem>
249where
250    F: Fn(u32) -> Option<ClipboardEntry>,
251{
252    let count = unsafe { CountClipboardFormats() };
253    let mut clipboard_format = 0;
254    for _ in 0..count {
255        clipboard_format = unsafe { EnumClipboardFormats(clipboard_format) };
256        let Some(item_format) = FORMATS_SET.get(&clipboard_format) else {
257            continue;
258        };
259        if let Some(entry) = f(*item_format) {
260            return Some(ClipboardItem {
261                entries: vec![entry],
262            });
263        }
264    }
265    // log the formats that we don't support yet.
266    {
267        clipboard_format = 0;
268        for _ in 0..count {
269            clipboard_format = unsafe { EnumClipboardFormats(clipboard_format) };
270            let mut buffer = [0u16; 64];
271            unsafe { GetClipboardFormatNameW(clipboard_format, &mut buffer) };
272            let format_name = String::from_utf16_lossy(&buffer);
273            log::warn!(
274                "Try to paste with unsupported clipboard format: {}, {}.",
275                clipboard_format,
276                format_name
277            );
278        }
279    }
280    None
281}
282
283fn read_string_from_clipboard() -> Option<ClipboardEntry> {
284    let text = with_clipboard_data(CF_UNICODETEXT.0 as u32, |data_ptr, _| {
285        let pcwstr = PCWSTR(data_ptr as *const u16);
286        String::from_utf16_lossy(unsafe { pcwstr.as_wide() })
287    })?;
288    let Some(hash) = read_hash_from_clipboard() else {
289        return Some(ClipboardEntry::String(ClipboardString::new(text)));
290    };
291    let Some(metadata) = read_metadata_from_clipboard() else {
292        return Some(ClipboardEntry::String(ClipboardString::new(text)));
293    };
294    if hash == ClipboardString::text_hash(&text) {
295        Some(ClipboardEntry::String(ClipboardString {
296            text,
297            metadata: Some(metadata),
298        }))
299    } else {
300        Some(ClipboardEntry::String(ClipboardString::new(text)))
301    }
302}
303
304fn read_hash_from_clipboard() -> Option<u64> {
305    if unsafe { IsClipboardFormatAvailable(*CLIPBOARD_HASH_FORMAT).is_err() } {
306        return None;
307    }
308    with_clipboard_data(*CLIPBOARD_HASH_FORMAT, |data_ptr, size| {
309        if size < 8 {
310            return None;
311        }
312        let hash_bytes: [u8; 8] = unsafe {
313            std::slice::from_raw_parts(data_ptr.cast::<u8>(), 8)
314                .try_into()
315                .ok()
316        }?;
317        Some(u64::from_ne_bytes(hash_bytes))
318    })?
319}
320
321fn read_metadata_from_clipboard() -> Option<String> {
322    unsafe { IsClipboardFormatAvailable(*CLIPBOARD_METADATA_FORMAT).ok()? };
323    with_clipboard_data(*CLIPBOARD_METADATA_FORMAT, |data_ptr, _size| {
324        let pcwstr = PCWSTR(data_ptr as *const u16);
325        String::from_utf16_lossy(unsafe { pcwstr.as_wide() })
326    })
327}
328
329fn read_image_from_clipboard(format: u32) -> Option<ClipboardEntry> {
330    let image_format = format_number_to_image_format(format)?;
331    read_image_for_type(format, *image_format)
332}
333
334#[inline]
335fn format_number_to_image_format(format_number: u32) -> Option<&'static ImageFormat> {
336    IMAGE_FORMATS_MAP.get(&format_number)
337}
338
339fn read_image_for_type(format_number: u32, format: ImageFormat) -> Option<ClipboardEntry> {
340    let (bytes, id) = with_clipboard_data(format_number, |data_ptr, size| {
341        let bytes = unsafe { std::slice::from_raw_parts(data_ptr as *mut u8 as _, size).to_vec() };
342        let id = hash(&bytes);
343        (bytes, id)
344    })?;
345    Some(ClipboardEntry::Image(Image { format, bytes, id }))
346}
347
348fn read_files_from_clipboard() -> Option<ClipboardEntry> {
349    let text = with_clipboard_data(CF_HDROP.0 as u32, |data_ptr, _size| {
350        let hdrop = HDROP(data_ptr);
351        let mut filenames = String::new();
352        with_file_names(hdrop, |file_name| {
353            filenames.push_str(&file_name);
354        });
355        filenames
356    })?;
357    Some(ClipboardEntry::String(ClipboardString {
358        text,
359        metadata: None,
360    }))
361}
362
363fn with_clipboard_data<F, R>(format: u32, f: F) -> Option<R>
364where
365    F: FnOnce(*mut std::ffi::c_void, usize) -> R,
366{
367    let global = HGLOBAL(unsafe { GetClipboardData(format).ok() }?.0);
368    let size = unsafe { GlobalSize(global) };
369    let data_ptr = unsafe { GlobalLock(global) };
370    let result = f(data_ptr, size);
371    unsafe { GlobalUnlock(global).ok() };
372    Some(result)
373}
374
375impl From<ImageFormat> for image::ImageFormat {
376    fn from(value: ImageFormat) -> Self {
377        match value {
378            ImageFormat::Png => image::ImageFormat::Png,
379            ImageFormat::Jpeg => image::ImageFormat::Jpeg,
380            ImageFormat::Webp => image::ImageFormat::WebP,
381            ImageFormat::Gif => image::ImageFormat::Gif,
382            // TODO: ImageFormat::Svg
383            ImageFormat::Bmp => image::ImageFormat::Bmp,
384            ImageFormat::Tiff => image::ImageFormat::Tiff,
385            _ => unreachable!(),
386        }
387    }
388}