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