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, OpenClipboard, RegisterClipboardFormatW,
 12            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::{Owned, PCWSTR};
 20
 21use gpui::{
 22    ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash,
 23};
 24
 25const DRAGDROP_GET_FILES_COUNT: u32 = 0xFFFFFFFF;
 26
 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
 40static IMAGE_FORMATS_MAP: LazyLock<FxHashMap<u32, ImageFormat>> = LazyLock::new(|| {
 41    let mut map = FxHashMap::default();
 42    map.insert(*CLIPBOARD_PNG_FORMAT, ImageFormat::Png);
 43    map.insert(*CLIPBOARD_GIF_FORMAT, ImageFormat::Gif);
 44    map.insert(*CLIPBOARD_JPG_FORMAT, ImageFormat::Jpeg);
 45    map.insert(*CLIPBOARD_SVG_FORMAT, ImageFormat::Svg);
 46    map
 47});
 48
 49fn register_clipboard_format(format: PCWSTR) -> u32 {
 50    let ret = unsafe { RegisterClipboardFormatW(format) };
 51    if ret == 0 {
 52        panic!(
 53            "Error when registering clipboard format: {}",
 54            std::io::Error::last_os_error()
 55        );
 56    }
 57    log::debug!(
 58        "Registered clipboard format {} as {}",
 59        unsafe { format.display() },
 60        ret
 61    );
 62    ret
 63}
 64
 65fn get_clipboard_data(format: u32) -> Option<LockedGlobal> {
 66    let global = HGLOBAL(unsafe { GetClipboardData(format).ok() }?.0);
 67    LockedGlobal::lock(global)
 68}
 69
 70pub(crate) fn write_to_clipboard(item: ClipboardItem) {
 71    let Some(_clip) = ClipboardGuard::open() else {
 72        return;
 73    };
 74
 75    let result: Result<()> = (|| {
 76        unsafe { EmptyClipboard()? };
 77        for entry in item.entries() {
 78            match entry {
 79                ClipboardEntry::String(string) => write_string(string)?,
 80                ClipboardEntry::Image(image) => write_image(image)?,
 81                ClipboardEntry::ExternalPaths(_) => {}
 82            }
 83        }
 84        Ok(())
 85    })();
 86
 87    if let Err(e) = result {
 88        log::error!("Failed to write to clipboard: {e}");
 89    }
 90}
 91
 92pub(crate) fn read_from_clipboard() -> Option<ClipboardItem> {
 93    let _clip = ClipboardGuard::open()?;
 94
 95    let mut entries = Vec::new();
 96    let mut have_text = false;
 97    let mut have_image = false;
 98    let mut have_files = false;
 99
100    let count = unsafe { CountClipboardFormats() };
101    let mut format = 0;
102    for _ in 0..count {
103        format = unsafe { EnumClipboardFormats(format) };
104
105        if !have_text && format == CF_UNICODETEXT.0 as u32 {
106            if let Some(entry) = read_string() {
107                entries.push(entry);
108                have_text = true;
109            }
110        } else if !have_image && is_image_format(format) {
111            if let Some(entry) = read_image(format) {
112                entries.push(entry);
113                have_image = true;
114            }
115        } else if !have_files && format == CF_HDROP.0 as u32 {
116            if let Some(entry) = read_files() {
117                entries.push(entry);
118                have_files = true;
119            }
120        }
121    }
122
123    if entries.is_empty() {
124        log_unsupported_clipboard_formats();
125        return None;
126    }
127    Some(ClipboardItem { entries })
128}
129
130pub(crate) fn with_file_names<F>(hdrop: HDROP, mut f: F)
131where
132    F: FnMut(String),
133{
134    let file_count = unsafe { DragQueryFileW(hdrop, DRAGDROP_GET_FILES_COUNT, None) };
135    for file_index in 0..file_count {
136        let filename_length = unsafe { DragQueryFileW(hdrop, file_index, None) } as usize;
137        let mut buffer = vec![0u16; filename_length + 1];
138        let ret = unsafe { DragQueryFileW(hdrop, file_index, Some(buffer.as_mut_slice())) };
139        if ret == 0 {
140            log::error!("unable to read file name of dragged file");
141            continue;
142        }
143        match String::from_utf16(&buffer[0..filename_length]) {
144            Ok(file_name) => f(file_name),
145            Err(e) => log::error!("dragged file name is not UTF-16: {}", e),
146        }
147    }
148}
149
150fn set_clipboard_bytes<T>(data: &[T], format: u32) -> Result<()> {
151    unsafe {
152        let global = Owned::new(GlobalAlloc(GMEM_MOVEABLE, std::mem::size_of_val(data))?);
153        let ptr = GlobalLock(*global);
154        anyhow::ensure!(!ptr.is_null(), "GlobalLock returned null");
155        std::ptr::copy_nonoverlapping(data.as_ptr(), ptr as _, data.len());
156        GlobalUnlock(*global).ok();
157        SetClipboardData(format, Some(HANDLE(global.0)))?;
158        // SetClipboardData succeeded — the system now owns the memory.
159        std::mem::forget(global);
160    }
161    Ok(())
162}
163
164fn get_clipboard_string(format: u32) -> Option<String> {
165    let locked = get_clipboard_data(format)?;
166    let bytes = locked.as_bytes();
167    let words_len = bytes.len() / std::mem::size_of::<u16>();
168    if words_len == 0 {
169        return Some(String::new());
170    }
171    let slice = unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const u16, words_len) };
172    let actual_len = slice.iter().position(|&c| c == 0).unwrap_or(words_len);
173    Some(String::from_utf16_lossy(&slice[..actual_len]))
174}
175
176fn is_image_format(format: u32) -> bool {
177    IMAGE_FORMATS_MAP.contains_key(&format) || format == CF_DIB.0 as u32
178}
179
180fn write_string(item: &ClipboardString) -> Result<()> {
181    let wide: Vec<u16> = item.text.encode_utf16().chain(Some(0)).collect_vec();
182    set_clipboard_bytes(&wide, CF_UNICODETEXT.0 as u32)?;
183
184    if let Some(metadata) = item.metadata.as_ref() {
185        let hash_bytes = ClipboardString::text_hash(&item.text).to_ne_bytes();
186        set_clipboard_bytes(&hash_bytes, *CLIPBOARD_HASH_FORMAT)?;
187
188        let wide: Vec<u16> = metadata.encode_utf16().chain(Some(0)).collect_vec();
189        set_clipboard_bytes(&wide, *CLIPBOARD_METADATA_FORMAT)?;
190    }
191    Ok(())
192}
193
194fn write_image(item: &Image) -> Result<()> {
195    let native_format = match item.format {
196        ImageFormat::Svg => Some(*CLIPBOARD_SVG_FORMAT),
197        ImageFormat::Gif => Some(*CLIPBOARD_GIF_FORMAT),
198        ImageFormat::Png => Some(*CLIPBOARD_PNG_FORMAT),
199        ImageFormat::Jpeg => Some(*CLIPBOARD_JPG_FORMAT),
200        _ => None,
201    };
202    if let Some(format) = native_format {
203        set_clipboard_bytes(item.bytes(), format)?;
204    }
205
206    // Also provide a PNG copy for broad compatibility.
207    // SVG can't be rasterized by the image crate, so skip it.
208    if item.format != ImageFormat::Svg && native_format != Some(*CLIPBOARD_PNG_FORMAT) {
209        if let Some(png_bytes) = convert_to_png(item.bytes(), item.format) {
210            set_clipboard_bytes(&png_bytes, *CLIPBOARD_PNG_FORMAT)?;
211        }
212    }
213    Ok(())
214}
215
216fn convert_to_png(bytes: &[u8], format: ImageFormat) -> Option<Vec<u8>> {
217    let img_format = gpui_to_image_format(format)?;
218    let image = image::load_from_memory_with_format(bytes, img_format)
219        .map_err(|e| log::warn!("Failed to decode image for PNG conversion: {e}"))
220        .ok()?;
221    let mut buf = Vec::new();
222    image
223        .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
224        .map_err(|e| log::warn!("Failed to encode PNG: {e}"))
225        .ok()?;
226    Some(buf)
227}
228
229fn read_string() -> Option<ClipboardEntry> {
230    let text = get_clipboard_string(CF_UNICODETEXT.0 as u32)?;
231    let metadata = read_clipboard_metadata(&text);
232    Some(ClipboardEntry::String(ClipboardString { text, metadata }))
233}
234
235fn read_clipboard_metadata(text: &str) -> Option<String> {
236    let locked = get_clipboard_data(*CLIPBOARD_HASH_FORMAT)?;
237    let hash_bytes: [u8; 8] = locked.as_bytes().get(..8)?.try_into().ok()?;
238    let hash = u64::from_ne_bytes(hash_bytes);
239    if hash != ClipboardString::text_hash(text) {
240        return None;
241    }
242    get_clipboard_string(*CLIPBOARD_METADATA_FORMAT)
243}
244
245fn read_image(format: u32) -> Option<ClipboardEntry> {
246    let locked = get_clipboard_data(format)?;
247    let (bytes, image_format) = if format == CF_DIB.0 as u32 {
248        (convert_dib_to_bmp(locked.as_bytes())?, ImageFormat::Bmp)
249    } else {
250        let image_format = *IMAGE_FORMATS_MAP.get(&format)?;
251        (locked.as_bytes().to_vec(), image_format)
252    };
253    let id = hash(&bytes);
254    Some(ClipboardEntry::Image(Image {
255        format: image_format,
256        bytes,
257        id,
258    }))
259}
260
261fn read_files() -> Option<ClipboardEntry> {
262    let locked = get_clipboard_data(CF_HDROP.0 as u32)?;
263    let hdrop = HDROP(locked.ptr as *mut _);
264    let mut filenames = Vec::new();
265    with_file_names(hdrop, |name| filenames.push(std::path::PathBuf::from(name)));
266    Some(ClipboardEntry::ExternalPaths(ExternalPaths(
267        filenames.into(),
268    )))
269}
270
271/// DIB is BMP without the 14-byte BITMAPFILEHEADER. Prepend one.
272fn convert_dib_to_bmp(dib: &[u8]) -> Option<Vec<u8>> {
273    if dib.len() < 40 {
274        return None;
275    }
276
277    let header_size = u32::from_le_bytes(dib[0..4].try_into().ok()?);
278    let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?);
279    let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
280
281    let color_table_size = if bit_count <= 8 {
282        let colors_used = u32::from_le_bytes(dib[32..36].try_into().ok()?);
283        (if colors_used == 0 {
284            1u32 << bit_count
285        } else {
286            colors_used
287        }) * 4
288    } else if compression == 3 {
289        12 // BI_BITFIELDS
290    } else {
291        0
292    };
293
294    let pixel_offset = 14 + header_size + color_table_size;
295    let file_size = 14 + dib.len() as u32;
296
297    let mut bmp = Vec::with_capacity(file_size as usize);
298    bmp.extend_from_slice(b"BM");
299    bmp.extend_from_slice(&file_size.to_le_bytes());
300    bmp.extend_from_slice(&[0u8; 4]); // reserved
301    bmp.extend_from_slice(&pixel_offset.to_le_bytes());
302    bmp.extend_from_slice(dib);
303    Some(bmp)
304}
305
306fn log_unsupported_clipboard_formats() {
307    let count = unsafe { CountClipboardFormats() };
308    let mut format = 0;
309    for _ in 0..count {
310        format = unsafe { EnumClipboardFormats(format) };
311        let mut buffer = [0u16; 64];
312        unsafe { GetClipboardFormatNameW(format, &mut buffer) };
313        let format_name = String::from_utf16_lossy(&buffer);
314        log::warn!(
315            "Try to paste with unsupported clipboard format: {}, {}.",
316            format,
317            format_name
318        );
319    }
320}
321
322fn gpui_to_image_format(value: ImageFormat) -> Option<image::ImageFormat> {
323    match value {
324        ImageFormat::Png => Some(image::ImageFormat::Png),
325        ImageFormat::Jpeg => Some(image::ImageFormat::Jpeg),
326        ImageFormat::Webp => Some(image::ImageFormat::WebP),
327        ImageFormat::Gif => Some(image::ImageFormat::Gif),
328        ImageFormat::Bmp => Some(image::ImageFormat::Bmp),
329        ImageFormat::Tiff => Some(image::ImageFormat::Tiff),
330        other => {
331            log::warn!("No image crate equivalent for format: {other:?}");
332            None
333        }
334    }
335}
336
337struct ClipboardGuard;
338
339impl ClipboardGuard {
340    fn open() -> Option<Self> {
341        match unsafe { OpenClipboard(None) } {
342            Ok(()) => Some(Self),
343            Err(e) => {
344                log::error!("Failed to open clipboard: {e}");
345                None
346            }
347        }
348    }
349}
350
351impl Drop for ClipboardGuard {
352    fn drop(&mut self) {
353        if let Err(e) = unsafe { CloseClipboard() } {
354            log::error!("Failed to close clipboard: {e}");
355        }
356    }
357}
358
359struct LockedGlobal {
360    global: HGLOBAL,
361    ptr: *const u8,
362    size: usize,
363}
364
365impl LockedGlobal {
366    fn lock(global: HGLOBAL) -> Option<Self> {
367        let size = unsafe { GlobalSize(global) };
368        let ptr = unsafe { GlobalLock(global) };
369        if ptr.is_null() {
370            return None;
371        }
372        Some(Self {
373            global,
374            ptr: ptr as *const u8,
375            size,
376        })
377    }
378
379    fn as_bytes(&self) -> &[u8] {
380        unsafe { std::slice::from_raw_parts(self.ptr, self.size) }
381    }
382}
383
384impl Drop for LockedGlobal {
385    fn drop(&mut self) {
386        unsafe { GlobalUnlock(self.global).ok() };
387    }
388}