clipboard.rs

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