@mariozechner/clipboard
Advanced tools
| #[cfg(feature = "image")] | ||
| use image::imageops::FilterType; | ||
| #[cfg(feature = "image")] | ||
| use image::{ColorType, DynamicImage, GenericImageView, ImageFormat, RgbaImage}; | ||
| use std::error::Error; | ||
| use std::io::Cursor; | ||
| pub type Result<T> = std::result::Result<T, Box<dyn Error + Send + Sync + 'static>>; | ||
| pub trait ContentData { | ||
| fn get_format(&self) -> ContentFormat; | ||
| fn as_bytes(&self) -> &[u8]; | ||
| fn as_str(&self) -> Result<&str>; | ||
| } | ||
| pub trait ClipboardHandler { | ||
| fn on_clipboard_change(&mut self); | ||
| } | ||
| pub enum ClipboardContent { | ||
| Text(String), | ||
| Rtf(String), | ||
| Html(String), | ||
| #[cfg(feature = "image")] | ||
| Image(RustImageData), | ||
| Files(Vec<String>), | ||
| Other(String, Vec<u8>), | ||
| } | ||
| impl ContentData for ClipboardContent { | ||
| fn get_format(&self) -> ContentFormat { | ||
| match self { | ||
| ClipboardContent::Text(_) => ContentFormat::Text, | ||
| ClipboardContent::Rtf(_) => ContentFormat::Rtf, | ||
| ClipboardContent::Html(_) => ContentFormat::Html, | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(_) => ContentFormat::Image, | ||
| ClipboardContent::Files(_) => ContentFormat::Files, | ||
| ClipboardContent::Other(format, _) => ContentFormat::Other(format.clone()), | ||
| } | ||
| } | ||
| fn as_bytes(&self) -> &[u8] { | ||
| match self { | ||
| ClipboardContent::Text(data) => data.as_bytes(), | ||
| ClipboardContent::Rtf(data) => data.as_bytes(), | ||
| ClipboardContent::Html(data) => data.as_bytes(), | ||
| // dynamic image is not supported to as bytes | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(_) => &[], | ||
| ClipboardContent::Files(data) => { | ||
| // use first file path as data | ||
| if let Some(path) = data.first() { | ||
| path.as_bytes() | ||
| } else { | ||
| &[] | ||
| } | ||
| } | ||
| ClipboardContent::Other(_, data) => data.as_slice(), | ||
| } | ||
| } | ||
| fn as_str(&self) -> Result<&str> { | ||
| match self { | ||
| ClipboardContent::Text(data) => Ok(data), | ||
| ClipboardContent::Rtf(data) => Ok(data), | ||
| ClipboardContent::Html(data) => Ok(data), | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(_) => Err("can't convert image to string".into()), | ||
| ClipboardContent::Files(data) => { | ||
| // use first file path as data | ||
| if let Some(path) = data.first() { | ||
| Ok(path) | ||
| } else { | ||
| Err("content is empty".into()) | ||
| } | ||
| } | ||
| ClipboardContent::Other(_, data) => std::str::from_utf8(data).map_err(|e| e.into()), | ||
| } | ||
| } | ||
| } | ||
| #[derive(Clone)] | ||
| pub enum ContentFormat { | ||
| Text, | ||
| Rtf, | ||
| Html, | ||
| #[cfg(feature = "image")] | ||
| Image, | ||
| Files, | ||
| Other(String), | ||
| } | ||
| #[cfg(feature = "image")] | ||
| pub struct RustImageData { | ||
| width: u32, | ||
| height: u32, | ||
| data: Option<DynamicImage>, | ||
| } | ||
| /// 此处的 `RustImageBuffer` 已经是带有图片格式的字节流,例如 png,jpeg; | ||
| #[cfg(feature = "image")] | ||
| pub struct RustImageBuffer(Vec<u8>); | ||
| #[cfg(feature = "image")] | ||
| pub trait RustImage: Sized { | ||
| /// create an empty image | ||
| fn empty() -> Self; | ||
| fn is_empty(&self) -> bool; | ||
| /// Read image from file path | ||
| fn from_path(path: &str) -> Result<Self>; | ||
| /// Create a new image from a byte slice | ||
| fn from_bytes(bytes: &[u8]) -> Result<Self>; | ||
| fn from_dynamic_image(image: DynamicImage) -> Self; | ||
| /// width and height | ||
| fn get_size(&self) -> (u32, u32); | ||
| /// Scale this image down to fit within a specific size. | ||
| /// Returns a new image. The image's aspect ratio is preserved. | ||
| /// The image is scaled to the maximum possible size that fits | ||
| /// within the bounds specified by `nwidth` and `nheight`. | ||
| /// | ||
| /// This method uses a fast integer algorithm where each source | ||
| /// pixel contributes to exactly one target pixel. | ||
| /// May give aliasing artifacts if new size is close to old size. | ||
| fn thumbnail(&self, width: u32, height: u32) -> Result<Self>; | ||
| /// en: Adjust the size of the image without retaining the aspect ratio | ||
| /// zh: 调整图片大小,不保留长宽比 | ||
| fn resize(&self, width: u32, height: u32, filter: FilterType) -> Result<Self>; | ||
| fn encode_image( | ||
| &self, | ||
| target_color_type: ColorType, | ||
| format: ImageFormat, | ||
| ) -> Result<RustImageBuffer>; | ||
| fn to_jpeg(&self) -> Result<RustImageBuffer>; | ||
| /// en: Convert to png format, the returned image is a new image, and the data itself will not be modified | ||
| /// zh: 转为 png 格式,返回的为新的图片,本身数据不会修改 | ||
| fn to_png(&self) -> Result<RustImageBuffer>; | ||
| #[cfg(target_os = "windows")] | ||
| fn to_bitmap(&self) -> Result<RustImageBuffer>; | ||
| fn save_to_path(&self, path: &str) -> Result<()>; | ||
| fn get_dynamic_image(&self) -> Result<DynamicImage>; | ||
| fn to_rgba8(&self) -> Result<RgbaImage>; | ||
| } | ||
| #[cfg(feature = "image")] | ||
| impl RustImage for RustImageData { | ||
| fn empty() -> Self { | ||
| RustImageData { | ||
| width: 0, | ||
| height: 0, | ||
| data: None, | ||
| } | ||
| } | ||
| fn is_empty(&self) -> bool { | ||
| self.data.is_none() | ||
| } | ||
| fn from_path(path: &str) -> Result<Self> { | ||
| let image = image::open(path)?; | ||
| let (width, height) = image.dimensions(); | ||
| Ok(RustImageData { | ||
| width, | ||
| height, | ||
| data: Some(image), | ||
| }) | ||
| } | ||
| fn from_bytes(bytes: &[u8]) -> Result<Self> { | ||
| let image = image::load_from_memory(bytes)?; | ||
| let (width, height) = image.dimensions(); | ||
| Ok(RustImageData { | ||
| width, | ||
| height, | ||
| data: Some(image), | ||
| }) | ||
| } | ||
| fn from_dynamic_image(image: DynamicImage) -> Self { | ||
| let (width, height) = image.dimensions(); | ||
| RustImageData { | ||
| width, | ||
| height, | ||
| data: Some(image), | ||
| } | ||
| } | ||
| fn get_size(&self) -> (u32, u32) { | ||
| (self.width, self.height) | ||
| } | ||
| fn thumbnail(&self, width: u32, height: u32) -> Result<Self> { | ||
| match &self.data { | ||
| Some(image) => { | ||
| let resized = image.thumbnail(width, height); | ||
| Ok(RustImageData { | ||
| width: resized.width(), | ||
| height: resized.height(), | ||
| data: Some(resized), | ||
| }) | ||
| } | ||
| None => Err("image is empty".into()), | ||
| } | ||
| } | ||
| fn resize(&self, width: u32, height: u32, filter: FilterType) -> Result<Self> { | ||
| match &self.data { | ||
| Some(image) => { | ||
| let resized = image.resize_exact(width, height, filter); | ||
| Ok(RustImageData { | ||
| width: resized.width(), | ||
| height: resized.height(), | ||
| data: Some(resized), | ||
| }) | ||
| } | ||
| None => Err("image is empty".into()), | ||
| } | ||
| } | ||
| fn save_to_path(&self, path: &str) -> Result<()> { | ||
| match &self.data { | ||
| Some(image) => { | ||
| image.save(path)?; | ||
| Ok(()) | ||
| } | ||
| None => Err("image is empty".into()), | ||
| } | ||
| } | ||
| fn get_dynamic_image(&self) -> Result<DynamicImage> { | ||
| match &self.data { | ||
| Some(image) => Ok(image.clone()), | ||
| None => Err("image is empty".into()), | ||
| } | ||
| } | ||
| fn to_rgba8(&self) -> Result<RgbaImage> { | ||
| match &self.data { | ||
| Some(image) => Ok(image.to_rgba8()), | ||
| None => Err("image is empty".into()), | ||
| } | ||
| } | ||
| // 私有辅助函数,处理图像格式转换和编码 | ||
| fn encode_image( | ||
| &self, | ||
| target_color_type: ColorType, | ||
| format: ImageFormat, | ||
| ) -> Result<RustImageBuffer> { | ||
| let image = self.data.as_ref().ok_or("image is empty")?; | ||
| let mut bytes = Vec::new(); | ||
| match (image.color(), target_color_type) { | ||
| (ColorType::Rgba8, ColorType::Rgb8) => image | ||
| .to_rgb8() | ||
| .write_to(&mut Cursor::new(&mut bytes), format)?, | ||
| (_, ColorType::Rgba8) => image | ||
| .to_rgba8() | ||
| .write_to(&mut Cursor::new(&mut bytes), format)?, | ||
| _ => image.write_to(&mut Cursor::new(&mut bytes), format)?, | ||
| }; | ||
| Ok(RustImageBuffer(bytes)) | ||
| } | ||
| fn to_jpeg(&self) -> Result<RustImageBuffer> { | ||
| // JPEG 需要 RGB 格式(不支持 alpha 通道) | ||
| self.encode_image(ColorType::Rgb8, ImageFormat::Jpeg) | ||
| } | ||
| fn to_png(&self) -> Result<RustImageBuffer> { | ||
| // PNG 使用 RGBA 格式以支持透明度 | ||
| self.encode_image(ColorType::Rgba8, ImageFormat::Png) | ||
| } | ||
| #[cfg(target_os = "windows")] | ||
| fn to_bitmap(&self) -> Result<RustImageBuffer> { | ||
| // BMP 使用 RGBA 格式 | ||
| self.encode_image(ColorType::Rgba8, ImageFormat::Bmp) | ||
| } | ||
| } | ||
| #[cfg(feature = "image")] | ||
| impl RustImageBuffer { | ||
| pub fn get_bytes(&self) -> &[u8] { | ||
| &self.0 | ||
| } | ||
| pub fn save_to_path(&self, path: &str) -> Result<()> { | ||
| std::fs::write(path, &self.0)?; | ||
| Ok(()) | ||
| } | ||
| } |
| pub mod common; | ||
| mod platform; | ||
| #[cfg(feature = "image")] | ||
| pub use common::RustImageData; | ||
| pub use common::{ClipboardContent, ClipboardHandler, ContentFormat, Result}; | ||
| #[cfg(feature = "image")] | ||
| pub use image::imageops::FilterType; | ||
| #[cfg(target_os = "linux")] | ||
| pub use platform::ClipboardContextX11Options; | ||
| pub use platform::{ClipboardContext, ClipboardWatcherContext, WatcherShutdown}; | ||
| pub trait Clipboard: Send { | ||
| /// zh: 获得剪切板当前内容的所有格式 | ||
| /// en: Get all formats of the current content in the clipboard | ||
| fn available_formats(&self) -> Result<Vec<String>>; | ||
| fn has(&self, format: ContentFormat) -> bool; | ||
| /// zh: 清空剪切板 | ||
| /// en: clear clipboard | ||
| fn clear(&self) -> Result<()>; | ||
| /// zh: 获得指定格式的数据,以字节数组形式返回 | ||
| /// en: Get the data in the specified format in the clipboard as a byte array | ||
| fn get_buffer(&self, format: &str) -> Result<Vec<u8>>; | ||
| /// zh: 仅获得无格式纯文本,以字符串形式返回 | ||
| /// en: Get plain text content in the clipboard as string | ||
| fn get_text(&self) -> Result<String>; | ||
| /// zh: 获得剪贴板中的富文本内容,以字符串形式返回 | ||
| /// en: Get the rich text content in the clipboard as string | ||
| fn get_rich_text(&self) -> Result<String>; | ||
| /// zh: 获得剪贴板中的html内容,以字符串形式返回 | ||
| /// en: Get the html format content in the clipboard as string | ||
| fn get_html(&self) -> Result<String>; | ||
| #[cfg(feature = "image")] | ||
| fn get_image(&self) -> Result<RustImageData>; | ||
| fn get_files(&self) -> Result<Vec<String>>; | ||
| fn get(&self, formats: &[ContentFormat]) -> Result<Vec<ClipboardContent>>; | ||
| fn set_buffer(&self, format: &str, buffer: Vec<u8>) -> Result<()>; | ||
| fn set_text(&self, text: String) -> Result<()>; | ||
| fn set_rich_text(&self, text: String) -> Result<()>; | ||
| fn set_html(&self, html: String) -> Result<()>; | ||
| #[cfg(feature = "image")] | ||
| fn set_image(&self, image: RustImageData) -> Result<()>; | ||
| fn set_files(&self, files: Vec<String>) -> Result<()>; | ||
| /// set image will clear clipboard | ||
| fn set(&self, contents: Vec<ClipboardContent>) -> Result<()>; | ||
| } | ||
| pub trait ClipboardWatcher<T: ClipboardHandler>: Send { | ||
| /// zh: 添加一个剪切板变化处理器,可以添加多个处理器,处理器需要实现 [`ClipboardHandler`] 这个trait | ||
| /// en: Add a clipboard change handler, you can add multiple handlers, the handler needs to implement the trait [`ClipboardHandler`] | ||
| fn add_handler(&mut self, handler: T) -> &mut Self; | ||
| /// zh: 开始监视剪切板变化,这是一个阻塞方法,直到监视结束,或者调用了stop方法,所以建议在单独的线程中调用 | ||
| /// en: Start monitoring clipboard changes, this is a blocking method, until the monitoring ends, or the stop method is called, so it is recommended to call it in a separate thread | ||
| fn start_watch(&mut self); | ||
| /// zh: 获得停止监视的通道,可以通过这个通道停止监视 | ||
| /// en: Get the channel to stop monitoring, you can stop monitoring through this channel | ||
| fn get_shutdown_channel(&self) -> WatcherShutdown; | ||
| } | ||
| #[cfg(not(all( | ||
| unix, | ||
| not(any( | ||
| target_os = "macos", | ||
| target_os = "ios", | ||
| target_os = "android", | ||
| target_os = "emscripten" | ||
| )) | ||
| )))] | ||
| impl WatcherShutdown { | ||
| /// zh: 停止监视 | ||
| /// | ||
| /// en: stop watching | ||
| pub fn stop(self) { | ||
| drop(self); | ||
| } | ||
| } |
| use crate::clipboard_rs::{ | ||
| common::Result, Clipboard, ClipboardContent, ClipboardHandler, ClipboardWatcher, ContentFormat, | ||
| }; | ||
| #[cfg(feature = "image")] | ||
| use crate::clipboard_rs::{common::RustImage, RustImageData}; | ||
| use objc2::{rc::Retained, runtime::ProtocolObject}; | ||
| use objc2_foundation::{ns_string, NSArray, NSData, NSDictionary, NSString}; | ||
| use objc2_ui_kit::UIPasteboard; | ||
| #[cfg(feature = "image")] | ||
| use objc2_ui_kit::{UIImage, UIImagePNGRepresentation}; | ||
| use std::{ | ||
| sync::mpsc::{self, Receiver, Sender}, | ||
| time::Duration, | ||
| }; | ||
| pub struct ClipboardContext { | ||
| clipboard: Retained<UIPasteboard>, | ||
| } | ||
| pub struct ClipboardWatcherContext<T: ClipboardHandler> { | ||
| clipboard: Retained<UIPasteboard>, | ||
| handlers: Vec<T>, | ||
| running: bool, | ||
| stop_signal: Sender<()>, | ||
| stop_receiver: Receiver<()>, | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub fn new() -> Result<Self> { | ||
| let clipboard = unsafe { UIPasteboard::generalPasteboard() }; | ||
| let (tx, rx) = mpsc::channel(); | ||
| Ok(Self { | ||
| clipboard, | ||
| handlers: Vec::new(), | ||
| running: false, | ||
| stop_signal: tx, | ||
| stop_receiver: rx, | ||
| }) | ||
| } | ||
| } | ||
| unsafe impl<T: ClipboardHandler> Send for ClipboardWatcherContext<T> {} | ||
| impl<T: ClipboardHandler> ClipboardWatcher<T> for ClipboardWatcherContext<T> { | ||
| fn add_handler(&mut self, handler: T) -> &mut Self { | ||
| self.handlers.push(handler); | ||
| self | ||
| } | ||
| fn start_watch(&mut self) { | ||
| if self.running { | ||
| println!("already start watch!"); | ||
| return; | ||
| } | ||
| if self.handlers.is_empty() { | ||
| println!("no handler, no need to start watch!"); | ||
| return; | ||
| } | ||
| self.running = true; | ||
| let mut last_change_count = unsafe { self.clipboard.changeCount() }; | ||
| loop { | ||
| // if receive stop signal, break loop | ||
| if self | ||
| .stop_receiver | ||
| .recv_timeout(Duration::from_millis(500)) | ||
| .is_ok() | ||
| { | ||
| break; | ||
| } | ||
| let change_count = unsafe { self.clipboard.changeCount() }; | ||
| if last_change_count == 0 { | ||
| last_change_count = change_count; | ||
| } else if change_count != last_change_count { | ||
| self | ||
| .handlers | ||
| .iter_mut() | ||
| .for_each(|handler| handler.on_clipboard_change()); | ||
| last_change_count = change_count; | ||
| } | ||
| } | ||
| self.running = false; | ||
| } | ||
| fn get_shutdown_channel(&self) -> WatcherShutdown { | ||
| WatcherShutdown { | ||
| stop_signal: self.stop_signal.clone(), | ||
| } | ||
| } | ||
| } | ||
| pub struct WatcherShutdown { | ||
| stop_signal: Sender<()>, | ||
| } | ||
| impl Drop for WatcherShutdown { | ||
| fn drop(&mut self) { | ||
| let _ = self.stop_signal.send(()); | ||
| } | ||
| } | ||
| impl ClipboardContext { | ||
| pub fn new() -> Result<Self> { | ||
| let clipboard = unsafe { UIPasteboard::generalPasteboard() }; | ||
| Ok(Self { clipboard }) | ||
| } | ||
| fn write_to_clipboard(&self, data: &[ClipboardContent]) -> Result<()> { | ||
| let items = data | ||
| .iter() | ||
| .map(|content| match content { | ||
| ClipboardContent::Text(text) => { | ||
| let ns_text = NSString::from_str(text); | ||
| let pair = unsafe { | ||
| NSDictionary::dictionaryWithObject_forKey( | ||
| ns_text.as_ref(), | ||
| ProtocolObject::from_ref(ns_string!("public.utf8-plain-text")), | ||
| ) | ||
| }; | ||
| Some(pair) | ||
| } | ||
| ClipboardContent::Rtf(rtf) => { | ||
| let ns_rtf = NSString::from_str(rtf); | ||
| let pair = unsafe { | ||
| NSDictionary::dictionaryWithObject_forKey( | ||
| ns_rtf.as_ref(), | ||
| ProtocolObject::from_ref(ns_string!("public.rtf")), | ||
| ) | ||
| }; | ||
| Some(pair) | ||
| } | ||
| ClipboardContent::Html(html) => { | ||
| let ns_html = NSString::from_str(html); | ||
| let pair = unsafe { | ||
| NSDictionary::dictionaryWithObject_forKey( | ||
| ns_html.as_ref(), | ||
| ProtocolObject::from_ref(ns_string!("public.html")), | ||
| ) | ||
| }; | ||
| Some(pair) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(image) => { | ||
| let png = image.to_png().unwrap(); | ||
| let ns_data = NSData::with_bytes(png.get_bytes()); | ||
| let image = unsafe { UIImage::imageWithData(&ns_data) }; | ||
| if let Some(image) = image { | ||
| let pair = unsafe { | ||
| NSDictionary::dictionaryWithObject_forKey( | ||
| image.as_ref(), | ||
| ProtocolObject::from_ref(ns_string!("public.png")), | ||
| ) | ||
| }; | ||
| Some(pair) | ||
| } else { | ||
| None | ||
| } | ||
| } | ||
| _ => None, | ||
| }) | ||
| .filter_map(|item| item) | ||
| .collect::<Vec<_>>(); | ||
| unsafe { | ||
| self | ||
| .clipboard | ||
| .setItems(&NSArray::from_retained_slice(&items)) | ||
| }; | ||
| Ok(()) | ||
| } | ||
| } | ||
| impl Clipboard for ClipboardContext { | ||
| fn available_formats(&self) -> Result<Vec<String>> { | ||
| let formats = unsafe { self.clipboard.pasteboardTypes() }; | ||
| Ok(formats.iter().map(|f| f.to_string()).collect()) | ||
| } | ||
| fn has(&self, format: ContentFormat) -> bool { | ||
| match format { | ||
| ContentFormat::Text => unsafe { self.clipboard.hasStrings() }, | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => unsafe { self.clipboard.hasImages() }, | ||
| ContentFormat::Rtf => unsafe { | ||
| self | ||
| .clipboard | ||
| .containsPasteboardTypes(&NSArray::from_slice(&[ns_string!("public.rtf")])) | ||
| }, | ||
| ContentFormat::Html => unsafe { | ||
| self | ||
| .clipboard | ||
| .containsPasteboardTypes(&NSArray::from_slice(&[ns_string!("public.html")])) | ||
| }, | ||
| ContentFormat::Files => false, | ||
| ContentFormat::Other(format) => unsafe { | ||
| self | ||
| .clipboard | ||
| .containsPasteboardTypes(&NSArray::from_retained_slice(&[NSString::from_str( | ||
| &format, | ||
| )])) | ||
| }, | ||
| } | ||
| } | ||
| fn clear(&self) -> Result<()> { | ||
| unsafe { self.clipboard.setItems(&NSArray::from_slice(&[])) }; | ||
| Ok(()) | ||
| } | ||
| fn get_buffer(&self, format: &str) -> Result<Vec<u8>> { | ||
| let ns_format = NSString::from_str(format); | ||
| let data = unsafe { self.clipboard.dataForPasteboardType(&ns_format) }; | ||
| if let Some(data) = data { | ||
| Ok(data.to_vec()) | ||
| } else { | ||
| Err("No data found".into()) | ||
| } | ||
| } | ||
| fn get_text(&self) -> Result<String> { | ||
| let text = unsafe { self.clipboard.string() }; | ||
| if let Some(text) = text { | ||
| Ok(text.to_string()) | ||
| } else { | ||
| Err("No text found".into()) | ||
| } | ||
| } | ||
| fn get_rich_text(&self) -> Result<String> { | ||
| let buffer = self.get_buffer("public.rtf")?; | ||
| Ok(String::from_utf8_lossy(&buffer).to_string()) | ||
| } | ||
| fn get_html(&self) -> Result<String> { | ||
| let buffer = self.get_buffer("public.html")?; | ||
| Ok(String::from_utf8_lossy(&buffer).to_string()) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn get_image(&self) -> Result<RustImageData> { | ||
| let image = unsafe { self.clipboard.image() }; | ||
| if let Some(image) = image { | ||
| let data = unsafe { UIImagePNGRepresentation(&image) }; | ||
| if let Some(data) = data { | ||
| let bytes = unsafe { data.as_bytes_unchecked() }; | ||
| Ok(RustImageData::from_bytes(bytes)?) | ||
| } else { | ||
| Err("No image data found".into()) | ||
| } | ||
| } else { | ||
| Err("No image found".into()) | ||
| } | ||
| } | ||
| fn get_files(&self) -> Result<Vec<String>> { | ||
| Err("Not supported".into()) | ||
| } | ||
| fn get(&self, _formats: &[ContentFormat]) -> Result<Vec<ClipboardContent>> { | ||
| Err("Not supported".into()) | ||
| } | ||
| fn set_buffer(&self, format: &str, buffer: Vec<u8>) -> Result<()> { | ||
| let ns_format = NSString::from_str(format); | ||
| let ns_data = NSData::with_bytes(&buffer); | ||
| unsafe { | ||
| self | ||
| .clipboard | ||
| .setData_forPasteboardType(&ns_data, &ns_format) | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn set_text(&self, text: String) -> Result<()> { | ||
| unsafe { | ||
| self | ||
| .clipboard | ||
| .setString(Some(&NSString::from_str(text.as_str()))) | ||
| }; | ||
| Ok(()) | ||
| } | ||
| fn set_rich_text(&self, _text: String) -> Result<()> { | ||
| Err("Not supported".into()) | ||
| } | ||
| fn set_html(&self, _html: String) -> Result<()> { | ||
| Err("Not supported".into()) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn set_image(&self, image: RustImageData) -> Result<()> { | ||
| if image.is_empty() { | ||
| Err("Image is empty".into()) | ||
| } else { | ||
| let png = image.to_png()?; | ||
| let ns_data = NSData::with_bytes(png.get_bytes()); | ||
| let image = unsafe { UIImage::imageWithData(&ns_data) }; | ||
| if let Some(image) = image { | ||
| unsafe { self.clipboard.setImage(Some(&image)) }; | ||
| Ok(()) | ||
| } else { | ||
| Err("Failed to create image".into()) | ||
| } | ||
| } | ||
| } | ||
| fn set_files(&self, _files: Vec<String>) -> Result<()> { | ||
| Err("Not supported".into()) | ||
| } | ||
| fn set(&self, contents: Vec<ClipboardContent>) -> Result<()> { | ||
| self.write_to_clipboard(&contents)?; | ||
| Ok(()) | ||
| } | ||
| } |
| use crate::clipboard_rs::common::Result; | ||
| #[cfg(feature = "image")] | ||
| use crate::clipboard_rs::common::{RustImage, RustImageData}; | ||
| use crate::clipboard_rs::{ | ||
| Clipboard, ClipboardContent, ClipboardHandler, ClipboardWatcher, ContentFormat, | ||
| }; | ||
| use objc2::rc::Retained; | ||
| use objc2::runtime::AnyObject; | ||
| use objc2::AllocAnyThread; | ||
| use objc2::ClassType; | ||
| use objc2::{rc::autoreleasepool, runtime::ProtocolObject}; | ||
| use objc2_app_kit::{ | ||
| NSImage, NSPasteboard, NSPasteboardItem, NSPasteboardType, NSPasteboardTypeFileURL, | ||
| NSPasteboardTypeHTML, NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeString, | ||
| NSPasteboardTypeTIFF, | ||
| }; | ||
| use objc2_foundation::{NSArray, NSData, NSString, NSURL}; | ||
| use std::ffi::c_void; | ||
| use std::sync::mpsc::{self, Receiver, Sender}; | ||
| use std::time::Duration; | ||
| use std::vec; | ||
| pub struct ClipboardContext { | ||
| pasteboard: Retained<NSPasteboard>, | ||
| } | ||
| pub struct ClipboardWatcherContext<T: ClipboardHandler> { | ||
| pasteboard: Retained<NSPasteboard>, | ||
| handlers: Vec<T>, | ||
| stop_signal: Sender<()>, | ||
| stop_receiver: Receiver<()>, | ||
| running: bool, | ||
| } | ||
| unsafe impl<T: ClipboardHandler> Send for ClipboardWatcherContext<T> {} | ||
| fn general_pasteboard() -> Result<Retained<NSPasteboard>> { | ||
| let pasteboard: *mut AnyObject = | ||
| unsafe { objc2::msg_send![NSPasteboard::class(), generalPasteboard] }; | ||
| let pasteboard = unsafe { Retained::retain_autoreleased(pasteboard) } | ||
| .ok_or("NSPasteboard generalPasteboard unavailable")?; | ||
| Ok(unsafe { Retained::cast_unchecked(pasteboard) }) | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub fn new() -> Result<Self> { | ||
| let ns_pasteboard = general_pasteboard()?; | ||
| let (tx, rx) = mpsc::channel(); | ||
| Ok(ClipboardWatcherContext { | ||
| pasteboard: ns_pasteboard, | ||
| handlers: Vec::new(), | ||
| stop_signal: tx, | ||
| stop_receiver: rx, | ||
| running: false, | ||
| }) | ||
| } | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcher<T> for ClipboardWatcherContext<T> { | ||
| fn add_handler(&mut self, handler: T) -> &mut Self { | ||
| self.handlers.push(handler); | ||
| self | ||
| } | ||
| fn start_watch(&mut self) { | ||
| if self.running { | ||
| println!("already start watch!"); | ||
| return; | ||
| } | ||
| if self.handlers.is_empty() { | ||
| println!("no handler, no need to start watch!"); | ||
| return; | ||
| } | ||
| self.running = true; | ||
| let mut last_change_count = self.pasteboard.changeCount(); | ||
| loop { | ||
| // if receive stop signal, break loop | ||
| if self | ||
| .stop_receiver | ||
| .recv_timeout(Duration::from_millis(500)) | ||
| .is_ok() | ||
| { | ||
| break; | ||
| } | ||
| let change_count = self.pasteboard.changeCount(); | ||
| if last_change_count == 0 { | ||
| last_change_count = change_count; | ||
| } else if change_count != last_change_count { | ||
| self | ||
| .handlers | ||
| .iter_mut() | ||
| .for_each(|handler| handler.on_clipboard_change()); | ||
| last_change_count = change_count; | ||
| } | ||
| } | ||
| self.running = false; | ||
| } | ||
| fn get_shutdown_channel(&self) -> WatcherShutdown { | ||
| WatcherShutdown { | ||
| stop_signal: self.stop_signal.clone(), | ||
| } | ||
| } | ||
| } | ||
| impl ClipboardContext { | ||
| pub fn new() -> Result<ClipboardContext> { | ||
| let ns_pasteboard = general_pasteboard()?; | ||
| let clipboard_ctx = ClipboardContext { | ||
| pasteboard: ns_pasteboard, | ||
| }; | ||
| Ok(clipboard_ctx) | ||
| } | ||
| fn plain(&self, r#type: &NSPasteboardType) -> Result<String> { | ||
| autoreleasepool(|_| { | ||
| let contents = self | ||
| .pasteboard | ||
| .pasteboardItems() | ||
| .ok_or("NSPasteboard#pasteboardItems errored")?; | ||
| for item in contents { | ||
| if let Some(string) = item.stringForType(r#type) { | ||
| return Ok(string.to_string()); | ||
| } | ||
| } | ||
| Err("No string found".into()) | ||
| }) | ||
| } | ||
| fn set_files(&self, files: &[String]) -> Result<()> { | ||
| autoreleasepool(|_| { | ||
| // Build NSArray<NSURL> and write via writeObjects for better compatibility | ||
| let urls: Vec<Retained<NSURL>> = files | ||
| .iter() | ||
| .filter_map(|file_path| { | ||
| // Normalize to local filesystem path, and verify it exists | ||
| let local_path = if file_path.starts_with("file://") { | ||
| file_path.trim_start_matches("file://").to_string() | ||
| } else { | ||
| file_path.clone() | ||
| }; | ||
| if !std::path::Path::new(&local_path).exists() { | ||
| return None; | ||
| } | ||
| let ns_path = NSString::from_str(&local_path); | ||
| // NSURL::fileURLWithPath returns a retained NSURL | ||
| let url = NSURL::fileURLWithPath(&ns_path); | ||
| Some(url) | ||
| }) | ||
| .collect(); | ||
| if urls.is_empty() { | ||
| return Err("no valid files".into()); | ||
| } | ||
| let write_objects = NSArray::from_retained_slice( | ||
| &urls | ||
| .iter() | ||
| .map(|u| ProtocolObject::from_retained(u.clone())) | ||
| .collect::<Vec<_>>(), | ||
| ); | ||
| if !self.pasteboard.writeObjects(&write_objects) { | ||
| return Err("writeObjects failed for files".into()); | ||
| } | ||
| Ok(()) | ||
| }) | ||
| } | ||
| // learn from https://github.com/zed-industries/zed/blob/79c1003b344ee513cf97ee8313c38c7c3f02c916/crates/gpui/src/platform/mac/platform.rs#L793 | ||
| fn write_to_clipboard(&self, data: &[ClipboardContent], with_clear: bool) -> Result<()> { | ||
| if with_clear { | ||
| self.pasteboard.clearContents(); | ||
| } | ||
| autoreleasepool(|_| { | ||
| // we create one NSPasteboardItem for all representations of the same content | ||
| let item = NSPasteboardItem::new(); | ||
| let mut has_content_other_than_files = false; | ||
| for d in data { | ||
| match d { | ||
| ClipboardContent::Text(text) => { | ||
| item.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }); | ||
| has_content_other_than_files = true; | ||
| } | ||
| ClipboardContent::Rtf(rtf) => { | ||
| let rtf_data = | ||
| unsafe { NSData::dataWithBytes_length(rtf.as_ptr() as *const c_void, rtf.len()) }; | ||
| item.setData_forType(&rtf_data, unsafe { NSPasteboardTypeRTF }); | ||
| has_content_other_than_files = true; | ||
| } | ||
| ClipboardContent::Html(html) => { | ||
| item.setString_forType(&NSString::from_str(html), unsafe { NSPasteboardTypeHTML }); | ||
| has_content_other_than_files = true; | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(image) => { | ||
| if let Ok(png_buffer) = image.to_png() { | ||
| let bytes = png_buffer.get_bytes(); | ||
| let ns_data = | ||
| unsafe { NSData::dataWithBytes_length(bytes.as_ptr() as *mut c_void, bytes.len()) }; | ||
| item.setData_forType(&ns_data, unsafe { NSPasteboardTypePNG }); | ||
| has_content_other_than_files = true; | ||
| }; | ||
| } | ||
| ClipboardContent::Files(files) => { | ||
| // Files are set seperately | ||
| let _ = self.set_files(files); | ||
| } | ||
| ClipboardContent::Other(format, buffer) => { | ||
| let ns_data = | ||
| unsafe { NSData::dataWithBytes_length(buffer.as_ptr() as *mut c_void, buffer.len()) }; | ||
| item.setData_forType(&ns_data, &NSString::from_str(format)); | ||
| has_content_other_than_files = true; | ||
| } | ||
| } | ||
| } | ||
| if has_content_other_than_files { | ||
| let write_objects = NSArray::from_retained_slice(&[ProtocolObject::from_retained(item)]); | ||
| if !self.pasteboard.writeObjects(&write_objects) { | ||
| return Err("writeObjects failed"); | ||
| } | ||
| } | ||
| Ok(()) | ||
| })?; | ||
| Ok(()) | ||
| } | ||
| } | ||
| unsafe impl Send for ClipboardContext {} | ||
| unsafe impl Sync for ClipboardContext {} | ||
| impl Clipboard for ClipboardContext { | ||
| fn available_formats(&self) -> Result<Vec<String>> { | ||
| let types = self | ||
| .pasteboard | ||
| .types() | ||
| .ok_or("NSPasteboard#types errored")?; | ||
| let res = types.iter().map(|t| t.to_string()).collect(); | ||
| Ok(res) | ||
| } | ||
| fn has(&self, format: ContentFormat) -> bool { | ||
| match format { | ||
| ContentFormat::Text => { | ||
| let types = NSArray::arrayWithObject(unsafe { NSPasteboardTypeString }); | ||
| // https://developer.apple.com/documentation/appkit/nspasteboard/1526078-availabletypefromarray?language=objc | ||
| // The first pasteboard type in types that is available on the pasteboard, or nil if the receiver does not contain any of the types in types. | ||
| // self.clipboard.availableTypeFromArray(types) | ||
| self.pasteboard.availableTypeFromArray(&types).is_some() | ||
| } | ||
| ContentFormat::Rtf => { | ||
| let types = NSArray::arrayWithObject(unsafe { NSPasteboardTypeRTF }); | ||
| self.pasteboard.availableTypeFromArray(&types).is_some() | ||
| } | ||
| ContentFormat::Html => { | ||
| // Currently only judge whether there is a public.html format | ||
| let types = NSArray::arrayWithObject(unsafe { NSPasteboardTypeHTML }); | ||
| self.pasteboard.availableTypeFromArray(&types).is_some() | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => { | ||
| // Currently only judge whether there is a png format | ||
| let types = NSArray::from_retained_slice(&[ | ||
| unsafe { NSPasteboardTypePNG }.to_owned(), | ||
| unsafe { NSPasteboardTypeTIFF }.to_owned(), | ||
| ]); | ||
| self.pasteboard.availableTypeFromArray(&types).is_some() | ||
| } | ||
| ContentFormat::Files => { | ||
| let types = NSArray::arrayWithObject(unsafe { NSPasteboardTypeFileURL }); | ||
| self.pasteboard.availableTypeFromArray(&types).is_some() | ||
| } | ||
| ContentFormat::Other(format) => { | ||
| let types = NSArray::from_retained_slice(&[NSString::from_str(&format)]); | ||
| self.pasteboard.availableTypeFromArray(&types).is_some() | ||
| } | ||
| } | ||
| } | ||
| fn clear(&self) -> Result<()> { | ||
| self.pasteboard.clearContents(); | ||
| Ok(()) | ||
| } | ||
| fn get_buffer(&self, format: &str) -> Result<Vec<u8>> { | ||
| if let Some(data) = self.pasteboard.dataForType(&NSString::from_str(format)) { | ||
| return Ok(data.to_vec()); | ||
| } | ||
| Err("no data".into()) | ||
| } | ||
| fn get_text(&self) -> Result<String> { | ||
| self.plain(unsafe { NSPasteboardTypeString }) | ||
| } | ||
| fn get_rich_text(&self) -> Result<String> { | ||
| self.plain(unsafe { NSPasteboardTypeRTF }) | ||
| } | ||
| fn get_html(&self) -> Result<String> { | ||
| self.plain(unsafe { NSPasteboardTypeHTML }) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn get_image(&self) -> Result<RustImageData> { | ||
| autoreleasepool(|_| { | ||
| let png_data = self.pasteboard.dataForType(unsafe { NSPasteboardTypePNG }); | ||
| if let Some(data) = png_data { | ||
| return RustImageData::from_bytes(&data.to_vec()); | ||
| }; | ||
| // if no png data, read NSImage; | ||
| let ns_image = NSImage::initWithPasteboard(NSImage::alloc(), &self.pasteboard); | ||
| if let Some(image) = ns_image { | ||
| let tiff_data = image.TIFFRepresentation(); | ||
| if let Some(data) = tiff_data { | ||
| return RustImageData::from_bytes(&data.to_vec()); | ||
| } | ||
| }; | ||
| Err("no image data".into()) | ||
| }) | ||
| } | ||
| fn get_files(&self) -> Result<Vec<String>> { | ||
| autoreleasepool(|_| { | ||
| let mut res = vec![]; | ||
| // 使用 readObjectsForClasses 读取 NSURL(文件 URL) | ||
| // 相当于 Objective-C: [pasteboard readObjectsForClasses:@[[NSURL class]] options:nil] | ||
| let classes = NSArray::arrayWithObject(NSURL::class()); | ||
| let objects = unsafe { | ||
| self | ||
| .pasteboard | ||
| .readObjectsForClasses_options(&classes, None) | ||
| }; | ||
| if let Some(objects) = objects { | ||
| for any_obj in objects { | ||
| // 尝试将返回对象视为 NSURL | ||
| if let Ok(url) = any_obj.downcast::<NSURL>() { | ||
| // 只接受文件 URL | ||
| if url.isFileURL() { | ||
| // 优先使用 path(去掉 file:// 前缀后的本地路径) | ||
| if let Some(path) = url.path() { | ||
| res.push(path.to_string()); | ||
| } else { | ||
| // 兜底使用 absoluteString,再去掉前缀 | ||
| let abs = if let Some(s) = url.absoluteString() { | ||
| s.to_string() | ||
| } else { | ||
| String::new() | ||
| }; | ||
| let file_path = if abs.starts_with("file://") { | ||
| abs.strip_prefix("file://").unwrap_or(&abs).to_string() | ||
| } else { | ||
| abs | ||
| }; | ||
| res.push(file_path); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| if res.is_empty() { | ||
| return Err("no files".into()); | ||
| } | ||
| Ok(res) | ||
| }) | ||
| } | ||
| fn get(&self, formats: &[ContentFormat]) -> Result<Vec<ClipboardContent>> { | ||
| autoreleasepool(|_| { | ||
| let contents = self | ||
| .pasteboard | ||
| .pasteboardItems() | ||
| .ok_or("NSPasteboard#pasteboardItems errored")?; | ||
| let mut results = Vec::new(); | ||
| for format in formats { | ||
| for item in contents.iter() { | ||
| match format { | ||
| ContentFormat::Text => { | ||
| if let Some(string) = item.stringForType(unsafe { NSPasteboardTypeString }) { | ||
| results.push(ClipboardContent::Text(string.to_string())); | ||
| break; | ||
| } | ||
| } | ||
| ContentFormat::Rtf => { | ||
| if let Some(string) = item.stringForType(unsafe { NSPasteboardTypeRTF }) { | ||
| results.push(ClipboardContent::Rtf(string.to_string())); | ||
| break; | ||
| } | ||
| } | ||
| ContentFormat::Html => { | ||
| if let Some(string) = item.stringForType(unsafe { NSPasteboardTypeHTML }) { | ||
| results.push(ClipboardContent::Html(string.to_string())); | ||
| break; | ||
| } | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => { | ||
| if let Ok(image) = self.get_image() { | ||
| results.push(ClipboardContent::Image(image)); | ||
| break; | ||
| } | ||
| } | ||
| ContentFormat::Files => { | ||
| if let Ok(files) = self.get_files() { | ||
| results.push(ClipboardContent::Files(files)); | ||
| break; | ||
| } | ||
| } | ||
| ContentFormat::Other(format_name) => { | ||
| if let Some(data) = item.dataForType(&NSString::from_str(format_name)) { | ||
| results.push(ClipboardContent::Other( | ||
| format_name.to_string(), | ||
| data.to_vec(), | ||
| )); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Ok(results) | ||
| }) | ||
| } | ||
| fn set_buffer(&self, format: &str, buffer: Vec<u8>) -> Result<()> { | ||
| self.write_to_clipboard(&[ClipboardContent::Other(format.to_owned(), buffer)], true) | ||
| } | ||
| fn set_text(&self, text: String) -> Result<()> { | ||
| self.write_to_clipboard(&[ClipboardContent::Text(text)], true) | ||
| } | ||
| fn set_rich_text(&self, text: String) -> Result<()> { | ||
| self.write_to_clipboard(&[ClipboardContent::Rtf(text)], true) | ||
| } | ||
| fn set_html(&self, html: String) -> Result<()> { | ||
| self.write_to_clipboard(&[ClipboardContent::Html(html)], true) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn set_image(&self, image: RustImageData) -> Result<()> { | ||
| self.write_to_clipboard(&[ClipboardContent::Image(image)], true) | ||
| } | ||
| fn set_files(&self, files: Vec<String>) -> Result<()> { | ||
| if files.is_empty() { | ||
| return Err("file list is empty".into()); | ||
| } | ||
| let _ = self.clear(); | ||
| self.set_files(&files) | ||
| } | ||
| fn set(&self, contents: Vec<ClipboardContent>) -> Result<()> { | ||
| if contents.is_empty() { | ||
| return Err( | ||
| "contents is empty, if you want to clear clipboard, please use clear method".into(), | ||
| ); | ||
| } | ||
| self.write_to_clipboard(&contents, true) | ||
| } | ||
| } | ||
| pub struct WatcherShutdown { | ||
| stop_signal: Sender<()>, | ||
| } | ||
| impl Drop for WatcherShutdown { | ||
| fn drop(&mut self) { | ||
| let _ = self.stop_signal.send(()); | ||
| } | ||
| } |
| #[cfg(target_os = "ios")] | ||
| mod ios; | ||
| #[cfg(target_os = "ios")] | ||
| pub use ios::{ClipboardContext, ClipboardWatcherContext, WatcherShutdown}; | ||
| #[cfg(target_os = "macos")] | ||
| mod macos; | ||
| #[cfg(target_os = "macos")] | ||
| pub use macos::{ClipboardContext, ClipboardWatcherContext, WatcherShutdown}; | ||
| #[cfg(target_os = "windows")] | ||
| mod win; | ||
| #[cfg(target_os = "windows")] | ||
| pub use win::{ClipboardContext, ClipboardWatcherContext, WatcherShutdown}; | ||
| // Linux: runtime detection between Wayland and X11 | ||
| #[cfg(all( | ||
| unix, | ||
| not(any( | ||
| target_os = "macos", | ||
| target_os = "ios", | ||
| target_os = "android", | ||
| target_os = "emscripten" | ||
| )) | ||
| ))] | ||
| mod x11; | ||
| #[cfg(all( | ||
| unix, | ||
| not(any( | ||
| target_os = "macos", | ||
| target_os = "ios", | ||
| target_os = "android", | ||
| target_os = "emscripten" | ||
| )), | ||
| feature = "wayland" | ||
| ))] | ||
| mod wayland; | ||
| #[cfg(all( | ||
| unix, | ||
| not(any( | ||
| target_os = "macos", | ||
| target_os = "ios", | ||
| target_os = "android", | ||
| target_os = "emscripten" | ||
| )) | ||
| ))] | ||
| pub use x11::ClipboardContextX11Options; | ||
| #[cfg(all( | ||
| unix, | ||
| not(any( | ||
| target_os = "macos", | ||
| target_os = "ios", | ||
| target_os = "android", | ||
| target_os = "emscripten" | ||
| )) | ||
| ))] | ||
| mod linux_clipboard { | ||
| use crate::clipboard_rs::{ | ||
| common::Result, Clipboard, ClipboardContent, ClipboardHandler, ContentFormat, | ||
| }; | ||
| #[cfg(feature = "image")] | ||
| use crate::RustImageData; | ||
| pub enum ClipboardContext { | ||
| X11(super::x11::ClipboardContext), | ||
| #[cfg(feature = "wayland")] | ||
| Wayland(super::wayland::ClipboardContext), | ||
| } | ||
| impl ClipboardContext { | ||
| pub fn new() -> Result<Self> { | ||
| #[cfg(feature = "wayland")] | ||
| { | ||
| if std::env::var_os("WAYLAND_DISPLAY").is_some() { | ||
| match super::wayland::ClipboardContext::new() { | ||
| Ok(ctx) => return Ok(Self::Wayland(ctx)), | ||
| Err(e) => { | ||
| eprintln!("Wayland clipboard init failed, falling back to X11: {}", e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Ok(Self::X11(super::x11::ClipboardContext::new()?)) | ||
| } | ||
| } | ||
| macro_rules! dispatch { | ||
| ($self:expr, $method:ident $(, $arg:expr)*) => { | ||
| match $self { | ||
| ClipboardContext::X11(ctx) => ctx.$method($($arg),*), | ||
| #[cfg(feature = "wayland")] | ||
| ClipboardContext::Wayland(ctx) => ctx.$method($($arg),*), | ||
| } | ||
| }; | ||
| } | ||
| impl Clipboard for ClipboardContext { | ||
| fn available_formats(&self) -> Result<Vec<String>> { | ||
| dispatch!(self, available_formats) | ||
| } | ||
| fn has(&self, format: ContentFormat) -> bool { | ||
| dispatch!(self, has, format) | ||
| } | ||
| fn clear(&self) -> Result<()> { | ||
| dispatch!(self, clear) | ||
| } | ||
| fn get_buffer(&self, format: &str) -> Result<Vec<u8>> { | ||
| dispatch!(self, get_buffer, format) | ||
| } | ||
| fn get_text(&self) -> Result<String> { | ||
| dispatch!(self, get_text) | ||
| } | ||
| fn get_rich_text(&self) -> Result<String> { | ||
| dispatch!(self, get_rich_text) | ||
| } | ||
| fn get_html(&self) -> Result<String> { | ||
| dispatch!(self, get_html) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn get_image(&self) -> Result<RustImageData> { | ||
| dispatch!(self, get_image) | ||
| } | ||
| fn get_files(&self) -> Result<Vec<String>> { | ||
| dispatch!(self, get_files) | ||
| } | ||
| fn get(&self, formats: &[ContentFormat]) -> Result<Vec<ClipboardContent>> { | ||
| dispatch!(self, get, formats) | ||
| } | ||
| fn set_buffer(&self, format: &str, buffer: Vec<u8>) -> Result<()> { | ||
| dispatch!(self, set_buffer, format, buffer) | ||
| } | ||
| fn set_text(&self, text: String) -> Result<()> { | ||
| dispatch!(self, set_text, text) | ||
| } | ||
| fn set_rich_text(&self, text: String) -> Result<()> { | ||
| dispatch!(self, set_rich_text, text) | ||
| } | ||
| fn set_html(&self, html: String) -> Result<()> { | ||
| dispatch!(self, set_html, html) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn set_image(&self, image: RustImageData) -> Result<()> { | ||
| dispatch!(self, set_image, image) | ||
| } | ||
| fn set_files(&self, files: Vec<String>) -> Result<()> { | ||
| dispatch!(self, set_files, files) | ||
| } | ||
| fn set(&self, contents: Vec<ClipboardContent>) -> Result<()> { | ||
| dispatch!(self, set, contents) | ||
| } | ||
| } | ||
| unsafe impl Send for ClipboardContext {} | ||
| pub enum ClipboardWatcherContext<T: ClipboardHandler> { | ||
| X11(super::x11::ClipboardWatcherContext<T>), | ||
| #[cfg(feature = "wayland")] | ||
| Wayland(super::wayland::ClipboardWatcherContext<T>), | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub fn new() -> Result<Self> { | ||
| #[cfg(feature = "wayland")] | ||
| { | ||
| if std::env::var_os("WAYLAND_DISPLAY").is_some() { | ||
| match super::wayland::ClipboardWatcherContext::new() { | ||
| Ok(ctx) => return Ok(Self::Wayland(ctx)), | ||
| Err(e) => { | ||
| eprintln!("Wayland watcher init failed, falling back to X11: {}", e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Ok(Self::X11(super::x11::ClipboardWatcherContext::new()?)) | ||
| } | ||
| } | ||
| impl<T: ClipboardHandler + Send> crate::ClipboardWatcher<T> for ClipboardWatcherContext<T> { | ||
| fn add_handler(&mut self, f: T) -> &mut Self { | ||
| match self { | ||
| Self::X11(ctx) => { | ||
| ctx.handlers.push(f); | ||
| } | ||
| #[cfg(feature = "wayland")] | ||
| Self::Wayland(ctx) => { | ||
| ctx.handlers.push(f); | ||
| } | ||
| } | ||
| self | ||
| } | ||
| fn start_watch(&mut self) { | ||
| match self { | ||
| Self::X11(ctx) => ctx.start_watch_inner(), | ||
| #[cfg(feature = "wayland")] | ||
| Self::Wayland(ctx) => ctx.start_watch_inner(), | ||
| } | ||
| } | ||
| fn get_shutdown_channel(&self) -> WatcherShutdown { | ||
| match self { | ||
| Self::X11(ctx) => WatcherShutdown { | ||
| _shutdown: Box::new(ctx.get_shutdown_channel()), | ||
| }, | ||
| #[cfg(feature = "wayland")] | ||
| Self::Wayland(ctx) => WatcherShutdown { | ||
| _shutdown: Box::new(ctx.get_shutdown_channel()), | ||
| }, | ||
| } | ||
| } | ||
| } | ||
| unsafe impl<T: ClipboardHandler + Send> Send for ClipboardWatcherContext<T> {} | ||
| pub struct WatcherShutdown { | ||
| _shutdown: Box<dyn std::any::Any + Send>, | ||
| } | ||
| impl WatcherShutdown { | ||
| pub fn stop(self) { | ||
| drop(self); | ||
| } | ||
| } | ||
| } | ||
| #[cfg(all( | ||
| unix, | ||
| not(any( | ||
| target_os = "macos", | ||
| target_os = "ios", | ||
| target_os = "android", | ||
| target_os = "emscripten" | ||
| )) | ||
| ))] | ||
| pub use linux_clipboard::{ClipboardContext, ClipboardWatcherContext, WatcherShutdown}; |
| use crate::clipboard_rs::{ | ||
| common::Result, Clipboard, ClipboardContent, ClipboardHandler, ContentFormat, | ||
| }; | ||
| #[cfg(feature = "image")] | ||
| use crate::clipboard_rs::{common::RustImage, RustImageData}; | ||
| use std::io::Read; | ||
| use std::sync::mpsc::{self, Receiver, Sender}; | ||
| use std::time::Duration; | ||
| use wl_clipboard_rs::{ | ||
| copy::{self, MimeSource, MimeType, Options, Source}, | ||
| paste::{self, get_contents, get_mime_types, ClipboardType, Seat}, | ||
| utils::is_primary_selection_supported, | ||
| }; | ||
| const MIME_HTML: &str = "text/html"; | ||
| const MIME_RTF: &str = "text/rtf"; | ||
| #[cfg(feature = "image")] | ||
| const MIME_PNG: &str = "image/png"; | ||
| const MIME_URI_LIST: &str = "text/uri-list"; | ||
| const FILE_PATH_PREFIX: &str = "file://"; | ||
| fn read_wayland_clipboard(mime: paste::MimeType) -> Result<Vec<u8>> { | ||
| let result = get_contents(ClipboardType::Regular, Seat::Unspecified, mime); | ||
| match result { | ||
| Ok((mut pipe, _)) => { | ||
| let mut buffer = vec![]; | ||
| pipe | ||
| .read_to_end(&mut buffer) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() })?; | ||
| Ok(buffer) | ||
| } | ||
| Err(paste::Error::ClipboardEmpty) | Err(paste::Error::NoMimeType) => { | ||
| Err("Clipboard is empty or content type not available".into()) | ||
| } | ||
| Err(e) => Err(e.to_string().into()), | ||
| } | ||
| } | ||
| fn write_wayland_clipboard(sources: Vec<MimeSource>) -> Result<()> { | ||
| let mut opts = Options::new(); | ||
| opts.foreground(false); | ||
| opts.clipboard(copy::ClipboardType::Regular); | ||
| opts | ||
| .copy_multi(sources) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() })?; | ||
| Ok(()) | ||
| } | ||
| fn get_offered_mime_types() -> Result<std::collections::HashSet<String>> { | ||
| get_mime_types(ClipboardType::Regular, Seat::Unspecified) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() }) | ||
| } | ||
| pub struct ClipboardContext; | ||
| impl ClipboardContext { | ||
| pub fn new() -> Result<Self> { | ||
| match is_primary_selection_supported() { | ||
| Ok(_) => Ok(Self), | ||
| Err(e) => Err(e.to_string().into()), | ||
| } | ||
| } | ||
| } | ||
| impl Clipboard for ClipboardContext { | ||
| fn available_formats(&self) -> Result<Vec<String>> { | ||
| let mime_types = get_offered_mime_types()?; | ||
| Ok(mime_types.into_iter().collect()) | ||
| } | ||
| fn has(&self, format: ContentFormat) -> bool { | ||
| #[allow(unreachable_patterns)] | ||
| let mime_to_check = match format { | ||
| ContentFormat::Text => "text/plain", | ||
| ContentFormat::Html => MIME_HTML, | ||
| ContentFormat::Rtf => MIME_RTF, | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => MIME_PNG, | ||
| ContentFormat::Files => MIME_URI_LIST, | ||
| ContentFormat::Other(ref s) => s.as_str(), | ||
| #[cfg(not(feature = "image"))] | ||
| _ => return false, | ||
| }; | ||
| if let Ok(mime_types) = get_offered_mime_types() { | ||
| // For text, also check text/plain;charset=utf-8 | ||
| if mime_to_check == "text/plain" { | ||
| mime_types.iter().any(|m| m.starts_with("text/plain")) | ||
| } else { | ||
| mime_types.contains(mime_to_check) | ||
| } | ||
| } else { | ||
| false | ||
| } | ||
| } | ||
| fn clear(&self) -> Result<()> { | ||
| copy::clear(copy::ClipboardType::Regular, copy::Seat::All) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() })?; | ||
| Ok(()) | ||
| } | ||
| fn get_buffer(&self, format: &str) -> Result<Vec<u8>> { | ||
| read_wayland_clipboard(paste::MimeType::Specific(format)) | ||
| } | ||
| fn get_text(&self) -> Result<String> { | ||
| let bytes = read_wayland_clipboard(paste::MimeType::Text)?; | ||
| String::from_utf8(bytes) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() }) | ||
| } | ||
| fn get_rich_text(&self) -> Result<String> { | ||
| let bytes = read_wayland_clipboard(paste::MimeType::Specific(MIME_RTF))?; | ||
| String::from_utf8(bytes) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() }) | ||
| } | ||
| fn get_html(&self) -> Result<String> { | ||
| let bytes = read_wayland_clipboard(paste::MimeType::Specific(MIME_HTML))?; | ||
| String::from_utf8(bytes) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() }) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn get_image(&self) -> Result<RustImageData> { | ||
| let bytes = read_wayland_clipboard(paste::MimeType::Specific(MIME_PNG))?; | ||
| RustImageData::from_bytes(&bytes) | ||
| } | ||
| fn get_files(&self) -> Result<Vec<String>> { | ||
| let bytes = read_wayland_clipboard(paste::MimeType::Specific(MIME_URI_LIST))?; | ||
| let text = String::from_utf8(bytes) | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() })?; | ||
| Ok( | ||
| text | ||
| .lines() | ||
| .map(|line| line.trim_end_matches('\r')) | ||
| .filter(|line| { | ||
| !line.starts_with('#') && !line.is_empty() && line.starts_with(FILE_PATH_PREFIX) | ||
| }) | ||
| .map(|line| line.to_string()) | ||
| .collect(), | ||
| ) | ||
| } | ||
| fn get(&self, formats: &[ContentFormat]) -> Result<Vec<ClipboardContent>> { | ||
| let mut contents = Vec::new(); | ||
| for format in formats { | ||
| #[allow(unreachable_patterns)] | ||
| match format { | ||
| ContentFormat::Text => { | ||
| if let Ok(text) = self.get_text() { | ||
| contents.push(ClipboardContent::Text(text)); | ||
| } | ||
| } | ||
| ContentFormat::Html => { | ||
| if let Ok(html) = self.get_html() { | ||
| contents.push(ClipboardContent::Html(html)); | ||
| } | ||
| } | ||
| ContentFormat::Rtf => { | ||
| if let Ok(rtf) = self.get_rich_text() { | ||
| contents.push(ClipboardContent::Rtf(rtf)); | ||
| } | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => { | ||
| if let Ok(image) = self.get_image() { | ||
| contents.push(ClipboardContent::Image(image)); | ||
| } | ||
| } | ||
| ContentFormat::Files => { | ||
| if let Ok(files) = self.get_files() { | ||
| contents.push(ClipboardContent::Files(files)); | ||
| } | ||
| } | ||
| ContentFormat::Other(mime) => { | ||
| if let Ok(data) = self.get_buffer(mime) { | ||
| contents.push(ClipboardContent::Other(mime.clone(), data)); | ||
| } | ||
| } | ||
| #[cfg(not(feature = "image"))] | ||
| _ => {} | ||
| } | ||
| } | ||
| Ok(contents) | ||
| } | ||
| fn set_buffer(&self, format: &str, buffer: Vec<u8>) -> Result<()> { | ||
| write_wayland_clipboard(vec![MimeSource { | ||
| source: Source::Bytes(buffer.into_boxed_slice()), | ||
| mime_type: MimeType::Specific(format.to_string()), | ||
| }]) | ||
| } | ||
| fn set_text(&self, text: String) -> Result<()> { | ||
| write_wayland_clipboard(vec![MimeSource { | ||
| source: Source::Bytes(text.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Text, | ||
| }]) | ||
| } | ||
| fn set_rich_text(&self, text: String) -> Result<()> { | ||
| write_wayland_clipboard(vec![MimeSource { | ||
| source: Source::Bytes(text.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Specific(MIME_RTF.to_string()), | ||
| }]) | ||
| } | ||
| fn set_html(&self, html: String) -> Result<()> { | ||
| write_wayland_clipboard(vec![MimeSource { | ||
| source: Source::Bytes(html.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Specific(MIME_HTML.to_string()), | ||
| }]) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn set_image(&self, image: RustImageData) -> Result<()> { | ||
| let bytes = image.to_png()?; | ||
| write_wayland_clipboard(vec![MimeSource { | ||
| source: Source::Bytes(bytes.get_bytes().into()), | ||
| mime_type: MimeType::Specific(MIME_PNG.to_string()), | ||
| }]) | ||
| } | ||
| fn set_files(&self, files: Vec<String>) -> Result<()> { | ||
| // Normalize paths to file:// URIs and use CRLF for text/uri-list | ||
| let uri_list: Vec<String> = files | ||
| .into_iter() | ||
| .map(|f| { | ||
| if f.starts_with(FILE_PATH_PREFIX) { | ||
| f | ||
| } else { | ||
| format!("{FILE_PATH_PREFIX}{f}") | ||
| } | ||
| }) | ||
| .collect(); | ||
| let data = uri_list.join("\r\n"); | ||
| write_wayland_clipboard(vec![MimeSource { | ||
| source: Source::Bytes(data.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Specific(MIME_URI_LIST.to_string()), | ||
| }]) | ||
| } | ||
| fn set(&self, contents: Vec<ClipboardContent>) -> Result<()> { | ||
| let mut sources = Vec::new(); | ||
| for content in contents { | ||
| #[allow(unreachable_patterns)] | ||
| match content { | ||
| ClipboardContent::Text(text) => { | ||
| sources.push(MimeSource { | ||
| source: Source::Bytes(text.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Text, | ||
| }); | ||
| } | ||
| ClipboardContent::Html(html) => { | ||
| sources.push(MimeSource { | ||
| source: Source::Bytes(html.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Specific(MIME_HTML.to_string()), | ||
| }); | ||
| } | ||
| ClipboardContent::Rtf(rtf) => { | ||
| sources.push(MimeSource { | ||
| source: Source::Bytes(rtf.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Specific(MIME_RTF.to_string()), | ||
| }); | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(image) => { | ||
| let bytes = image.to_png()?; | ||
| sources.push(MimeSource { | ||
| source: Source::Bytes(bytes.get_bytes().into()), | ||
| mime_type: MimeType::Specific(MIME_PNG.to_string()), | ||
| }); | ||
| } | ||
| ClipboardContent::Files(files) => { | ||
| let uri_list: Vec<String> = files | ||
| .into_iter() | ||
| .map(|f| { | ||
| if f.starts_with(FILE_PATH_PREFIX) { | ||
| f | ||
| } else { | ||
| format!("{FILE_PATH_PREFIX}{f}") | ||
| } | ||
| }) | ||
| .collect(); | ||
| let data = uri_list.join("\r\n"); | ||
| sources.push(MimeSource { | ||
| source: Source::Bytes(data.into_bytes().into_boxed_slice()), | ||
| mime_type: MimeType::Specific(MIME_URI_LIST.to_string()), | ||
| }); | ||
| } | ||
| ClipboardContent::Other(mime, data) => { | ||
| sources.push(MimeSource { | ||
| source: Source::Bytes(data.into_boxed_slice()), | ||
| mime_type: MimeType::Specific(mime), | ||
| }); | ||
| } | ||
| #[cfg(not(feature = "image"))] | ||
| _ => {} | ||
| } | ||
| } | ||
| write_wayland_clipboard(sources) | ||
| } | ||
| } | ||
| unsafe impl Send for ClipboardContext {} | ||
| // Polling-based clipboard watcher for Wayland | ||
| pub struct ClipboardWatcherContext<T: ClipboardHandler> { | ||
| pub(crate) handlers: Vec<T>, | ||
| pub(crate) stop_signal: Sender<()>, | ||
| stop_receiver: Receiver<()>, | ||
| } | ||
| unsafe impl<T: ClipboardHandler + Send> Send for ClipboardWatcherContext<T> {} | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub fn new() -> Result<Self> { | ||
| // Verify data-control protocol is available (same check as ClipboardContext) | ||
| is_primary_selection_supported() | ||
| .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() })?; | ||
| let (tx, rx) = mpsc::channel(); | ||
| Ok(Self { | ||
| handlers: Vec::new(), | ||
| stop_signal: tx, | ||
| stop_receiver: rx, | ||
| }) | ||
| } | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub(crate) fn get_shutdown_channel(&self) -> WatcherShutdown { | ||
| WatcherShutdown { | ||
| sender: self.stop_signal.clone(), | ||
| } | ||
| } | ||
| pub(crate) fn start_watch_inner(&mut self) { | ||
| let mut last_mime_types: Vec<String> = Vec::new(); | ||
| let mut last_text = String::new(); | ||
| // Get initial clipboard state | ||
| if let Ok(types) = get_offered_mime_types() { | ||
| last_mime_types = { | ||
| let mut v: Vec<String> = types.into_iter().collect(); | ||
| v.sort(); | ||
| v | ||
| }; | ||
| } | ||
| if let Ok(text) = read_wayland_clipboard(paste::MimeType::Text) { | ||
| if let Ok(s) = String::from_utf8(text) { | ||
| last_text = s; | ||
| } | ||
| } | ||
| loop { | ||
| if self | ||
| .stop_receiver | ||
| .recv_timeout(Duration::from_millis(500)) | ||
| .is_ok() | ||
| { | ||
| break; | ||
| } | ||
| let changed = if let Ok(types) = get_offered_mime_types() { | ||
| let mut current_types: Vec<String> = types.into_iter().collect(); | ||
| current_types.sort(); | ||
| if current_types != last_mime_types { | ||
| // MIME types changed, clipboard definitely changed | ||
| last_mime_types = current_types; | ||
| // Update text cache too | ||
| if let Ok(bytes) = read_wayland_clipboard(paste::MimeType::Text) { | ||
| if let Ok(s) = String::from_utf8(bytes) { | ||
| last_text = s; | ||
| } | ||
| } else { | ||
| last_text.clear(); | ||
| } | ||
| true | ||
| } else if let Ok(bytes) = read_wayland_clipboard(paste::MimeType::Text) { | ||
| // Same MIME types, check if text content changed | ||
| if let Ok(current) = String::from_utf8(bytes) { | ||
| if current != last_text { | ||
| last_text = current; | ||
| true | ||
| } else { | ||
| false | ||
| } | ||
| } else { | ||
| false | ||
| } | ||
| } else if !last_text.is_empty() { | ||
| last_text.clear(); | ||
| true | ||
| } else { | ||
| false | ||
| } | ||
| } else { | ||
| // No clipboard content available | ||
| if !last_mime_types.is_empty() { | ||
| last_mime_types.clear(); | ||
| last_text.clear(); | ||
| true | ||
| } else { | ||
| false | ||
| } | ||
| }; | ||
| if changed { | ||
| self | ||
| .handlers | ||
| .iter_mut() | ||
| .for_each(|handler| handler.on_clipboard_change()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| pub struct WatcherShutdown { | ||
| pub(crate) sender: Sender<()>, | ||
| } | ||
| impl Drop for WatcherShutdown { | ||
| fn drop(&mut self) { | ||
| let _ = self.sender.send(()); | ||
| } | ||
| } |
| use std::collections::HashMap; | ||
| use std::io::Cursor; | ||
| use std::sync::mpsc::{Receiver, Sender}; | ||
| use std::time::Duration; | ||
| use std::{mem, ptr, thread}; | ||
| use crate::clipboard_rs::common::{ContentData, Result}; | ||
| #[cfg(feature = "image")] | ||
| use crate::clipboard_rs::common::{RustImage, RustImageData}; | ||
| use crate::clipboard_rs::{ | ||
| Clipboard, ClipboardContent, ClipboardHandler, ClipboardWatcher, ContentFormat, | ||
| }; | ||
| use clipboard_win::raw::{set_file_list_with, set_string_with, set_without_clear}; | ||
| use clipboard_win::types::c_uint; | ||
| use clipboard_win::{ | ||
| formats, get, get_clipboard, options, raw, set_clipboard, Clipboard as ClipboardWin, Monitor, | ||
| SysResult, | ||
| }; | ||
| #[cfg(feature = "image")] | ||
| use image::codecs::bmp::BmpDecoder; | ||
| #[cfg(feature = "image")] | ||
| use image::DynamicImage; | ||
| use windows::Win32::Foundation::{HANDLE, HWND}; | ||
| use windows::Win32::Graphics::Gdi::{ | ||
| CreateDIBitmap, DeleteObject, GetDC, ReleaseDC, BITMAPFILEHEADER, BITMAPINFO, BITMAPINFOHEADER, | ||
| BITMAPV5HEADER, CBM_INIT, DIB_RGB_COLORS, HDC, HGDIOBJ, | ||
| }; | ||
| use windows::Win32::System::DataExchange::SetClipboardData; | ||
| pub struct WatcherShutdown { | ||
| stop_signal: Sender<()>, | ||
| } | ||
| static UNKNOWN_FORMAT: &str = "unknown format"; | ||
| static CF_RTF: &str = "Rich Text Format"; | ||
| static CF_HTML: &str = "HTML Format"; | ||
| static CF_PNG: &str = "PNG"; | ||
| pub struct ClipboardContext { | ||
| format_map: HashMap<&'static str, c_uint>, | ||
| html_format: formats::Html, | ||
| } | ||
| pub struct ClipboardWatcherContext<T: ClipboardHandler> { | ||
| handlers: Vec<T>, | ||
| stop_signal: Sender<()>, | ||
| stop_receiver: Receiver<()>, | ||
| running: bool, | ||
| } | ||
| unsafe impl Send for ClipboardContext {} | ||
| unsafe impl Sync for ClipboardContext {} | ||
| unsafe impl<T: ClipboardHandler> Send for ClipboardWatcherContext<T> {} | ||
| unsafe impl<T: ClipboardHandler> Sync for ClipboardWatcherContext<T> {} | ||
| impl ClipboardContext { | ||
| pub fn new() -> Result<ClipboardContext> { | ||
| let (format_map, html_format) = { | ||
| let cf_html_format = formats::Html::new(); | ||
| let cf_rtf_uint = clipboard_win::register_format(CF_RTF); | ||
| let cf_png_uint = clipboard_win::register_format(CF_PNG); | ||
| let mut m: HashMap<&str, c_uint> = HashMap::new(); | ||
| if let Some(cf_html) = cf_html_format { | ||
| m.insert(CF_HTML, cf_html.code()); | ||
| } | ||
| if let Some(cf_rtf) = cf_rtf_uint { | ||
| m.insert(CF_RTF, cf_rtf.get()); | ||
| } | ||
| if let Some(cf_png) = cf_png_uint { | ||
| m.insert(CF_PNG, cf_png.get()); | ||
| } | ||
| (m, cf_html_format) | ||
| }; | ||
| Ok(ClipboardContext { | ||
| format_map, | ||
| html_format: html_format.ok_or("register html format error")?, | ||
| }) | ||
| } | ||
| fn get_format(&self, format: &ContentFormat) -> c_uint { | ||
| match format { | ||
| ContentFormat::Text => formats::CF_UNICODETEXT, | ||
| ContentFormat::Rtf => *self.format_map.get(CF_RTF).unwrap(), | ||
| ContentFormat::Html => *self.format_map.get(CF_HTML).unwrap(), | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => formats::CF_DIB, | ||
| ContentFormat::Files => formats::CF_HDROP, | ||
| ContentFormat::Other(format) => clipboard_win::register_format(format).unwrap().get(), | ||
| } | ||
| } | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub fn new() -> Result<Self> { | ||
| let (tx, rx) = std::sync::mpsc::channel(); | ||
| Ok(Self { | ||
| handlers: Vec::new(), | ||
| stop_signal: tx, | ||
| stop_receiver: rx, | ||
| running: false, | ||
| }) | ||
| } | ||
| } | ||
| impl Clipboard for ClipboardContext { | ||
| fn available_formats(&self) -> Result<Vec<String>> { | ||
| let _clip = | ||
| ClipboardWin::new_attempts(10).map_err(|code| format!("Open clipboard error, code = {code}")); | ||
| let format_count = clipboard_win::count_formats(); | ||
| if format_count.is_none() { | ||
| return Ok(Vec::new()); | ||
| } | ||
| let mut res = Vec::new(); | ||
| let enum_formats = clipboard_win::raw::EnumFormats::new(); | ||
| enum_formats.into_iter().for_each(|format| { | ||
| let f_name = raw::format_name_big(format); | ||
| match f_name { | ||
| Some(name) => res.push(name), | ||
| None => { | ||
| res.push(UNKNOWN_FORMAT.to_string()); | ||
| } | ||
| } | ||
| }); | ||
| Ok(res) | ||
| } | ||
| fn has(&self, format: ContentFormat) -> bool { | ||
| match format { | ||
| ContentFormat::Text => clipboard_win::is_format_avail(formats::CF_UNICODETEXT), | ||
| ContentFormat::Rtf => { | ||
| let cf_rtf_uint = self.format_map.get(CF_RTF).unwrap(); | ||
| clipboard_win::is_format_avail(*cf_rtf_uint) | ||
| } | ||
| ContentFormat::Html => { | ||
| let cf_html_uint = self.format_map.get(CF_HTML).unwrap(); | ||
| clipboard_win::is_format_avail(*cf_html_uint) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => { | ||
| // Currently only judge whether there is a png format | ||
| let cf_png_uint = self.format_map.get(CF_PNG).unwrap(); | ||
| clipboard_win::is_format_avail(*cf_png_uint) | ||
| || clipboard_win::is_format_avail(formats::CF_DIB) | ||
| } | ||
| ContentFormat::Files => clipboard_win::is_format_avail(formats::CF_HDROP), | ||
| ContentFormat::Other(format) => { | ||
| let format_uint = clipboard_win::register_format(format.as_str()); | ||
| if let Some(format_uint) = format_uint { | ||
| return clipboard_win::is_format_avail(format_uint.get()); | ||
| } | ||
| false | ||
| } | ||
| } | ||
| } | ||
| fn clear(&self) -> Result<()> { | ||
| let _clip = | ||
| ClipboardWin::new_attempts(10).map_err(|code| format!("Open clipboard error, code = {code}")); | ||
| let res = clipboard_win::empty(); | ||
| if let Err(e) = res { | ||
| return Err(format!("Empty clipboard error, code = {e}").into()); | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn get_buffer(&self, format: &str) -> Result<Vec<u8>> { | ||
| let format_uint = clipboard_win::register_format(format); | ||
| if format_uint.is_none() { | ||
| return Err("register format error".into()); | ||
| } | ||
| let format_uint = format_uint.unwrap().get(); | ||
| let buffer = get_clipboard(formats::RawData(format_uint)); | ||
| match buffer { | ||
| Ok(data) => Ok(data), | ||
| Err(e) => Err(format!("Get buffer error, code = {e}").into()), | ||
| } | ||
| } | ||
| fn get_text(&self) -> Result<String> { | ||
| let string: SysResult<String> = get_clipboard(formats::Unicode); | ||
| match string { | ||
| Ok(s) => Ok(s), | ||
| Err(e) => Err(format!("Get text error, code = {e}").into()), | ||
| } | ||
| } | ||
| fn get_rich_text(&self) -> Result<String> { | ||
| let rtf_raw_data = self.get_buffer(CF_RTF)?; | ||
| Ok(String::from_utf8_lossy(&rtf_raw_data).to_string()) | ||
| } | ||
| fn get_html(&self) -> Result<String> { | ||
| let buffer = get_clipboard(formats::RawData(self.html_format.code())); | ||
| match buffer { | ||
| Ok(data) => { | ||
| let html_res = String::from_utf8(data); | ||
| if let Ok(html_full_str) = html_res { | ||
| let html = extract_html_from_clipboard_data(html_full_str.as_str()); | ||
| if let Ok(html) = html { | ||
| return Ok(html); | ||
| } | ||
| } | ||
| Err("Get html error".into()) | ||
| } | ||
| Err(e) => Err(format!("Get buffer error, code = {e}").into()), | ||
| } | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn get_image(&self) -> Result<RustImageData> { | ||
| let cf_png_format = self.format_map.get(CF_PNG); | ||
| if cf_png_format.is_some() && clipboard_win::is_format_avail(*cf_png_format.unwrap()) { | ||
| let image_raw_data = self.get_buffer(CF_PNG)?; | ||
| RustImageData::from_bytes(&image_raw_data) | ||
| } else if clipboard_win::is_format_avail(formats::CF_DIBV5) { | ||
| let res = get_clipboard(formats::RawData(formats::CF_DIBV5)); | ||
| match res { | ||
| Ok(data) => { | ||
| let decoder = { | ||
| // if data.as_slice().starts_with(b"BM") { | ||
| // BmpDecoder::new(Cursor::new(data.as_slice())) | ||
| // } else { | ||
| BmpDecoder::new_without_file_header(Cursor::new(data.as_slice())) | ||
| // } | ||
| }; | ||
| let decoder = decoder.map_err(|e| format!("{e}"))?; | ||
| let dynamic_image = DynamicImage::from_decoder(decoder).map_err(|e| format!("{e}"))?; | ||
| Ok(RustImageData::from_dynamic_image(dynamic_image)) | ||
| } | ||
| Err(e) => Err(format!("Get image error, code = {e}").into()), | ||
| } | ||
| } else if clipboard_win::is_format_avail(formats::CF_DIB) { | ||
| let res = get_clipboard(formats::Bitmap); | ||
| match res { | ||
| Ok(data) => RustImageData::from_bytes(&data), | ||
| Err(e) => Err(format!("Get image error, code = {e}").into()), | ||
| } | ||
| } else { | ||
| Err("No image data in clipboard".into()) | ||
| } | ||
| } | ||
| fn get_files(&self) -> Result<Vec<String>> { | ||
| let files: SysResult<Vec<String>> = get_clipboard(formats::FileList); | ||
| match files { | ||
| Ok(f) => Ok(f), | ||
| Err(e) => Err(format!("Get files error, code = {e}").into()), | ||
| } | ||
| } | ||
| fn get(&self, formats: &[ContentFormat]) -> Result<Vec<ClipboardContent>> { | ||
| let _clip = | ||
| ClipboardWin::new_attempts(10).map_err(|code| format!("Open clipboard error, code = {code}")); | ||
| let mut res = Vec::new(); | ||
| for format in formats { | ||
| match format { | ||
| ContentFormat::Text => { | ||
| let r = get(formats::Unicode); | ||
| match r { | ||
| Ok(txt) => { | ||
| res.push(ClipboardContent::Text(txt)); | ||
| } | ||
| Err(_) => continue, | ||
| } | ||
| } | ||
| ContentFormat::Rtf => { | ||
| let format_uint = self.get_format(format); | ||
| let buffer = get(formats::RawData(format_uint)); | ||
| match buffer { | ||
| Ok(buffer) => { | ||
| let rtf = String::from_utf8_lossy(&buffer); | ||
| res.push(ClipboardContent::Rtf(rtf.to_string())); | ||
| } | ||
| Err(_) => continue, | ||
| } | ||
| } | ||
| ContentFormat::Html => { | ||
| let html_buffer = get(formats::RawData(self.html_format.code())); | ||
| match html_buffer { | ||
| Ok(html) => { | ||
| let html_res = String::from_utf8(html); | ||
| if let Ok(html_full_str) = html_res { | ||
| let html = extract_html_from_clipboard_data(html_full_str.as_str()); | ||
| if let Ok(html) = html { | ||
| res.push(ClipboardContent::Html(html)); | ||
| } | ||
| } | ||
| } | ||
| Err(_) => continue, | ||
| } | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => { | ||
| let img = self.get_image(); | ||
| match img { | ||
| Ok(img) => { | ||
| res.push(ClipboardContent::Image(img)); | ||
| } | ||
| Err(_) => continue, | ||
| } | ||
| } | ||
| ContentFormat::Other(fmt) => { | ||
| let format_uint = self.get_format(format); | ||
| let buffer = get(formats::RawData(format_uint)); | ||
| match buffer { | ||
| Ok(buffer) => { | ||
| res.push(ClipboardContent::Other(fmt.clone(), buffer)); | ||
| } | ||
| Err(_) => continue, | ||
| } | ||
| } | ||
| ContentFormat::Files => { | ||
| let files = self.get_files(); | ||
| match files { | ||
| Ok(files) => { | ||
| res.push(ClipboardContent::Files(files)); | ||
| } | ||
| Err(_) => continue, | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Ok(res) | ||
| } | ||
| fn set_buffer(&self, format: &str, buffer: Vec<u8>) -> Result<()> { | ||
| let format_uint = clipboard_win::register_format(format); | ||
| if format_uint.is_none() { | ||
| return Err("register format error".into()); | ||
| } | ||
| let format_uint = format_uint.unwrap().get(); | ||
| let res = set_clipboard(formats::RawData(format_uint), buffer); | ||
| if res.is_err() { | ||
| return Err("set buffer error".into()); | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn set_text(&self, text: String) -> Result<()> { | ||
| let res = set_clipboard(formats::Unicode, text); | ||
| res.map_err(|e| format!("set text error, code = {e}").into()) | ||
| } | ||
| fn set_rich_text(&self, text: String) -> Result<()> { | ||
| let res = self.set_buffer(CF_RTF, text.as_bytes().to_vec()); | ||
| res.map_err(|e| format!("set rich text error, code = {e}").into()) | ||
| } | ||
| fn set_html(&self, html: String) -> Result<()> { | ||
| let cf_html = plain_html_to_cf_html(&html); | ||
| let res = set_clipboard( | ||
| formats::RawData(self.html_format.code()), | ||
| cf_html.as_bytes(), | ||
| ); | ||
| res.map_err(|e| format!("set html error, code = {e}").into()) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn set_image(&self, image: RustImageData) -> Result<()> { | ||
| let _clip = | ||
| ClipboardWin::new_attempts(10).map_err(|code| format!("Open clipboard error, code = {code}")); | ||
| let res = clipboard_win::empty(); | ||
| if let Err(e) = res { | ||
| return Err(format!("Empty clipboard error, code = {e}").into()); | ||
| } | ||
| // chromium source code | ||
| // @link {https://source.chromium.org/chromium/chromium/src/+/main:ui/base/clipboard/clipboard_win.cc;l=771;drc=2a5aaed0ff3a0895c8551495c2656ed49baf742c;bpv=0;bpt=1} | ||
| let cf_png_format = self.format_map.get(CF_PNG); | ||
| if let Some(cf_png) = cf_png_format { | ||
| let png = image.to_png()?; | ||
| if let Err(e) = set_without_clear(*cf_png, png.get_bytes()) { | ||
| eprintln!("set png image error, code = {e}"); | ||
| // continue set bmp image | ||
| } | ||
| } | ||
| // 转换为 BMP 并设置到剪贴板 | ||
| let bmp = image | ||
| .to_bitmap() | ||
| .map_err(|e| format!("transform to bitmap error, code = {e}"))?; | ||
| set_bitmap_inner(bmp.get_bytes()).map_err(|e| format!("set image error, code = {e}").into()) | ||
| } | ||
| fn set_files(&self, files: Vec<String>) -> Result<()> { | ||
| let _clip = | ||
| ClipboardWin::new_attempts(10).map_err(|code| format!("Open clipboard error, code = {code}")); | ||
| let res = set_file_list_with(&files, options::DoClear); | ||
| res.map_err(|e| format!("set files error, code = {e}").into()) | ||
| } | ||
| fn set(&self, contents: Vec<ClipboardContent>) -> Result<()> { | ||
| let _clip = | ||
| ClipboardWin::new_attempts(10).map_err(|code| format!("Open clipboard error, code = {code}")); | ||
| let res = clipboard_win::empty(); | ||
| if let Err(e) = res { | ||
| return Err(format!("Empty clipboard error, code = {e}").into()); | ||
| } | ||
| for content in contents { | ||
| match content { | ||
| ClipboardContent::Text(txt) => { | ||
| let res = set_string_with(txt.as_str(), options::NoClear); | ||
| if res.is_err() { | ||
| continue; | ||
| } | ||
| } | ||
| ClipboardContent::Html(html) => { | ||
| let format_uint_html = self.html_format.code(); | ||
| let cf_html = plain_html_to_cf_html(&html); | ||
| let res = set_without_clear(format_uint_html, cf_html.as_bytes()); | ||
| if res.is_err() { | ||
| continue; | ||
| } | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(img) => { | ||
| // set image will clear clipboard | ||
| let res = self.set_image(img); | ||
| if res.is_err() { | ||
| continue; | ||
| } | ||
| } | ||
| ClipboardContent::Rtf(_) | ClipboardContent::Other(_, _) => { | ||
| let format_uint = self.get_format(&content.get_format()); | ||
| let res = set_without_clear(format_uint, content.as_bytes()); | ||
| if res.is_err() { | ||
| continue; | ||
| } | ||
| } | ||
| ClipboardContent::Files(file_list) => { | ||
| let res = set_file_list_with(&file_list, options::NoClear); | ||
| if res.is_err() { | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcher<T> for ClipboardWatcherContext<T> { | ||
| fn add_handler(&mut self, f: T) -> &mut Self { | ||
| self.handlers.push(f); | ||
| self | ||
| } | ||
| fn start_watch(&mut self) { | ||
| if self.running { | ||
| println!("already start watch!"); | ||
| return; | ||
| } | ||
| if self.handlers.is_empty() { | ||
| println!("no handler, no need to start watch!"); | ||
| return; | ||
| } | ||
| self.running = true; | ||
| let mut monitor = Monitor::new().expect("create monitor error"); | ||
| let shutdown = monitor.shutdown_channel(); | ||
| loop { | ||
| if self.stop_receiver.try_recv().is_ok() { | ||
| break; | ||
| } | ||
| let msg = monitor.try_recv(); | ||
| match msg { | ||
| Ok(true) => { | ||
| self.handlers.iter_mut().for_each(|f| { | ||
| f.on_clipboard_change(); | ||
| }); | ||
| } | ||
| Ok(false) => { | ||
| // no change | ||
| thread::park_timeout(Duration::from_millis(200)); | ||
| continue; | ||
| } | ||
| Err(e) => { | ||
| eprintln!("watch error, code = {e}"); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| drop(shutdown); | ||
| self.running = false; | ||
| } | ||
| fn get_shutdown_channel(&self) -> WatcherShutdown { | ||
| WatcherShutdown { | ||
| stop_signal: self.stop_signal.clone(), | ||
| } | ||
| } | ||
| } | ||
| impl Drop for WatcherShutdown { | ||
| fn drop(&mut self) { | ||
| let _ = self.stop_signal.send(()); | ||
| } | ||
| } | ||
| // 将输入的 UTF-8 字符串转换为宽字符(UTF-16)字符串 | ||
| // fn utf8_to_utf16(input: &str) -> Vec<u16> { | ||
| // let mut vec: Vec<u16> = input.encode_utf16().collect(); | ||
| // vec.push(0); | ||
| // vec | ||
| // } | ||
| // https://learn.microsoft.com/en-us/windows/win32/dataxchg/html-clipboard-format | ||
| // The description header includes the clipboard version number and offsets, indicating where the context and the fragment start and end. The description is a list of ASCII text keywords followed by a string and separated by a colon (:). | ||
| // Version: vv version number of the clipboard. Starting version is . As of Windows 10 20H2 this is now .Version:0.9Version:1.0 | ||
| // StartHTML: Offset (in bytes) from the beginning of the clipboard to the start of the context, or if no context.-1 | ||
| // EndHTML: Offset (in bytes) from the beginning of the clipboard to the end of the context, or if no context.-1 | ||
| // StartFragment: Offset (in bytes) from the beginning of the clipboard to the start of the fragment. | ||
| // EndFragment: Offset (in bytes) from the beginning of the clipboard to the end of the fragment. | ||
| // StartSelection: Optional. Offset (in bytes) from the beginning of the clipboard to the start of the selection. | ||
| // EndSelection: Optional. Offset (in bytes) from the beginning of the clipboard to the end of the selection. | ||
| // The and keywords are optional and must both be omitted if you do not want the application to generate this information.StartSelectionEndSelection | ||
| // Future revisions of the clipboard format may extend the header, for example, since the HTML starts at the offset then multiple and pairs could be added later to support noncontiguous selection of fragments.CF_HTMLStartHTMLStartFragmentEndFragment | ||
| // example: | ||
| // html=Version:1.0 | ||
| // StartHTML:000000096 | ||
| // EndHTML:000000375 | ||
| // StartFragment:000000096 | ||
| // EndFragment:000000375 | ||
| // <html><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"></head><body><div style="background-color:#2b2b2b;color:#a9b7c6;font-family:'JetBrains Mono',monospace;font-size:9.8pt;"><pre><span style="color:#9876aa;">sellChannel</span></pre></div></body></html> | ||
| // cp from https://github.com/Devolutions/IronRDP/blob/37aa6426dba3272f38a2bb46a513144a326854ee/crates/ironrdp-cliprdr-format/src/html.rs#L91 | ||
| fn plain_html_to_cf_html(fragment: &str) -> String { | ||
| const POS_PLACEHOLDER: &str = "0000000000"; | ||
| let mut buffer = String::new(); | ||
| let mut write_header = |key: &str, value: &str| { | ||
| let size = key.len() + value.len() + ":\r\n".len(); | ||
| buffer.reserve(size); | ||
| buffer.push_str(key); | ||
| buffer.push(':'); | ||
| let value_pos = buffer.len(); | ||
| buffer.push_str(value); | ||
| buffer.push_str("\r\n"); | ||
| value_pos | ||
| }; | ||
| write_header("Version", "0.9"); | ||
| let start_html_header_value_pos = write_header("StartHTML", POS_PLACEHOLDER); | ||
| let end_html_header_value_pos = write_header("EndHTML", POS_PLACEHOLDER); | ||
| let start_fragment_header_value_pos = write_header("StartFragment", POS_PLACEHOLDER); | ||
| let end_fragment_header_value_pos = write_header("EndFragment", POS_PLACEHOLDER); | ||
| let start_html_pos = buffer.len(); | ||
| if !fragment.starts_with("<html>") { | ||
| buffer.push_str("<html>\r\n<body>\r\n<!--StartFragment-->"); | ||
| } | ||
| let start_fragment_pos = buffer.len(); | ||
| buffer.push_str(fragment); | ||
| let end_fragment_pos = buffer.len(); | ||
| if !fragment.ends_with("</html>") { | ||
| buffer.push_str("<!--EndFragment-->\r\n</body>\r\n</html>"); | ||
| } | ||
| let end_html_pos = buffer.len(); | ||
| let start_html_pos_value = format!("{start_html_pos:0>10}"); | ||
| let end_html_pos_value = format!("{end_html_pos:0>10}"); | ||
| let start_fragment_pos_value = format!("{start_fragment_pos:0>10}"); | ||
| let end_fragment_pos_value = format!("{end_fragment_pos:0>10}"); | ||
| let mut replace_placeholder = |value_begin_idx: usize, header_value: &str| { | ||
| let value_end_idx = value_begin_idx + POS_PLACEHOLDER.len(); | ||
| buffer.replace_range(value_begin_idx..value_end_idx, header_value); | ||
| }; | ||
| replace_placeholder(start_html_header_value_pos, &start_html_pos_value); | ||
| replace_placeholder(end_html_header_value_pos, &end_html_pos_value); | ||
| replace_placeholder(start_fragment_header_value_pos, &start_fragment_pos_value); | ||
| replace_placeholder(end_fragment_header_value_pos, &end_fragment_pos_value); | ||
| buffer | ||
| } | ||
| const SEP: char = ':'; | ||
| const START_HTML: &str = "StartHTML"; | ||
| const END_HTML: &str = "EndHTML"; | ||
| fn extract_html_from_clipboard_data(data: &str) -> Result<String> { | ||
| let mut start_idx = 0usize; | ||
| let mut end_idx = data.len(); | ||
| for line in data.lines() { | ||
| let mut split = line.split(SEP); | ||
| let key = match split.next() { | ||
| Some(key) => key, | ||
| None => break, | ||
| }; | ||
| let value = match split.next() { | ||
| Some(value) => value, | ||
| //Reached HTML | ||
| None => break, | ||
| }; | ||
| match key { | ||
| START_HTML => match value.trim_start_matches('0').parse() { | ||
| Ok(value) => { | ||
| start_idx = value; | ||
| continue; | ||
| } | ||
| //Should not really happen | ||
| Err(_) => break, | ||
| }, | ||
| END_HTML => match value.trim_start_matches('0').parse() { | ||
| Ok(value) => { | ||
| end_idx = value; | ||
| continue; | ||
| } | ||
| //Should not really happen | ||
| Err(_) => break, | ||
| }, | ||
| _ => continue, | ||
| } | ||
| } | ||
| //Make sure HTML writer didn't screw up offsets of fragment | ||
| // Check that start_idx is within bounds | ||
| if start_idx > data.len() { | ||
| return Err("Invalid HTML offsets: start index exceeds data length".into()); | ||
| } | ||
| // Check that end_idx is within bounds | ||
| if end_idx > data.len() { | ||
| return Err("Invalid HTML offsets: end index exceeds data length".into()); | ||
| } | ||
| // Check that end_idx >= start_idx | ||
| if end_idx < start_idx { | ||
| return Err("Invalid HTML offsets: end index before start index".into()); | ||
| } | ||
| Ok(data[start_idx..end_idx].to_string()) | ||
| } | ||
| fn set_bitmap_inner(data: &[u8]) -> Result<()> { | ||
| const FILE_HEADER_LEN: usize = mem::size_of::<BITMAPFILEHEADER>(); | ||
| const INFO_HEADER_LEN: usize = mem::size_of::<BITMAPV5HEADER>(); | ||
| if data.len() <= (FILE_HEADER_LEN + INFO_HEADER_LEN) { | ||
| return Err("Invalid bitmap data".into()); | ||
| } | ||
| let mut file_header = mem::MaybeUninit::<BITMAPFILEHEADER>::uninit(); | ||
| let mut info_header = mem::MaybeUninit::<BITMAPV5HEADER>::uninit(); | ||
| let (file_header, info_header) = unsafe { | ||
| ptr::copy_nonoverlapping( | ||
| data.as_ptr(), | ||
| file_header.as_mut_ptr() as _, | ||
| FILE_HEADER_LEN, | ||
| ); | ||
| ptr::copy_nonoverlapping( | ||
| data.as_ptr().add(FILE_HEADER_LEN), | ||
| info_header.as_mut_ptr() as _, | ||
| INFO_HEADER_LEN, | ||
| ); | ||
| (file_header.assume_init(), info_header.assume_init()) | ||
| }; | ||
| if data.len() <= file_header.bfOffBits as usize { | ||
| return Err("Invalid bitmap data".into()); | ||
| } | ||
| let bitmap = &data[file_header.bfOffBits as _..]; | ||
| if bitmap.len() < info_header.bV5SizeImage as usize { | ||
| return Err("Invalid bitmap data".into()); | ||
| } | ||
| let dc = DeviceContext::new()?; | ||
| let handle = unsafe { | ||
| CreateDIBitmap( | ||
| dc.0, | ||
| Some(&info_header as *const _ as *const BITMAPINFOHEADER), | ||
| CBM_INIT as u32, | ||
| Some(bitmap.as_ptr() as _), | ||
| Some(&info_header as *const _ as *const BITMAPINFO), | ||
| DIB_RGB_COLORS, | ||
| ) | ||
| }; | ||
| if handle.is_invalid() { | ||
| return Err("Failed to create DIB".into()); | ||
| } | ||
| if let Err(err) = unsafe { SetClipboardData(formats::CF_BITMAP, Some(HANDLE(handle.0))) } { | ||
| let _ = unsafe { DeleteObject(HGDIOBJ(handle.0)) }; | ||
| Err(err.into()) | ||
| } else { | ||
| Ok(()) | ||
| } | ||
| } | ||
| struct DeviceContext(HDC); | ||
| impl DeviceContext { | ||
| fn new() -> Result<Self> { | ||
| let dc = unsafe { GetDC(Some(HWND::default())) }; | ||
| if dc.is_invalid() { | ||
| return Err("Failed to get DC".into()); | ||
| } | ||
| Ok(Self(dc)) | ||
| } | ||
| } | ||
| impl Drop for DeviceContext { | ||
| fn drop(&mut self) { | ||
| unsafe { ReleaseDC(Some(HWND::default()), self.0) }; | ||
| } | ||
| } |
| use crate::clipboard_rs::{ | ||
| common::Result, | ||
| // #[cfg(feature = "image")] | ||
| ClipboardContent, | ||
| ClipboardHandler, | ||
| ContentFormat, | ||
| }; | ||
| #[cfg(feature = "image")] | ||
| use crate::clipboard_rs::{common::RustImage, RustImageData}; | ||
| use crate::Clipboard; | ||
| use std::sync::mpsc::{self, Receiver, Sender}; | ||
| use std::{ | ||
| sync::{Arc, RwLock}, | ||
| thread, | ||
| time::{Duration, Instant}, | ||
| }; | ||
| use x11rb::{ | ||
| connection::Connection, | ||
| protocol::{ | ||
| xfixes, | ||
| xproto::{ | ||
| Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, Property, | ||
| SelectionNotifyEvent, SelectionRequestEvent, WindowClass, SELECTION_NOTIFY_EVENT, | ||
| }, | ||
| Event, | ||
| }, | ||
| rust_connection::RustConnection, | ||
| wrapper::ConnectionExt as _, | ||
| COPY_DEPTH_FROM_PARENT, CURRENT_TIME, | ||
| }; | ||
| x11rb::atom_manager! { | ||
| pub Atoms: AtomCookies { | ||
| CLIPBOARD, | ||
| CLIPBOARD_MANAGER, | ||
| PROPERTY, | ||
| SAVE_TARGETS, | ||
| TARGETS, | ||
| ATOM, | ||
| INCR, | ||
| TIMESTAMP, | ||
| MULTIPLE, | ||
| UTF8_STRING, | ||
| UTF8_MIME_0: b"text/plain;charset=utf-8", | ||
| UTF8_MIME_1: b"text/plain;charset=UTF-8", | ||
| // Text in ISO Latin-1 encoding | ||
| // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2 | ||
| STRING, | ||
| // Text in unknown encoding | ||
| // See: https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2 | ||
| TEXT, | ||
| TEXT_MIME_UNKNOWN: b"text/plain", | ||
| // Rich Text Format | ||
| RTF: b"text/rtf", | ||
| RTF_1: b"text/richtext", | ||
| HTML: b"text/html", | ||
| PNG_MIME: b"image/png", | ||
| FILE_LIST: b"text/uri-list", | ||
| GNOME_COPY_FILES: b"x-special/gnome-copied-files", | ||
| NAUTILUS_FILE_LIST: b"x-special/nautilus-clipboard", | ||
| } | ||
| } | ||
| pub const DEFAULT_READ_TIMEOUT: u64 = 500; | ||
| // zh: 用于创建 X11 剪贴板上下文的选项 | ||
| // en: Options for creating an X11 clipboard context | ||
| pub struct ClipboardContextX11Options { | ||
| // zh: 剪贴板读取操作超时 | ||
| // en: Timeout for clipboard read operations | ||
| pub read_timeout: Option<Duration>, | ||
| } | ||
| const FILE_PATH_PREFIX: &str = "file://"; | ||
| pub struct ClipboardContext { | ||
| inner: Arc<InnerContext>, | ||
| read_timeout: Option<Duration>, | ||
| } | ||
| struct ClipboardData { | ||
| format: Atom, | ||
| data: Vec<u8>, | ||
| } | ||
| struct InnerContext { | ||
| server: XServerContext, | ||
| server_for_write: XServerContext, | ||
| ignore_formats: Vec<Atom>, | ||
| // 此刻待写入的剪贴板内容 | ||
| wait_write_data: RwLock<Vec<ClipboardData>>, | ||
| } | ||
| impl InnerContext { | ||
| pub fn new() -> Result<Self> { | ||
| let server = XServerContext::new()?; | ||
| let server_for_write = XServerContext::new()?; | ||
| let wait_write_data = RwLock::new(Vec::new()); | ||
| let ignore_formats = vec![ | ||
| server.atoms.TIMESTAMP, | ||
| server.atoms.MULTIPLE, | ||
| server.atoms.TARGETS, | ||
| server.atoms.SAVE_TARGETS, | ||
| ]; | ||
| Ok(Self { | ||
| server, | ||
| server_for_write, | ||
| ignore_formats, | ||
| wait_write_data, | ||
| }) | ||
| } | ||
| pub fn handle_selection_request(&self, event: SelectionRequestEvent) -> Result<()> { | ||
| let success; | ||
| let ctx = &self.server_for_write; | ||
| let atoms = ctx.atoms; | ||
| // we are asked for a list of supported conversion targets | ||
| if event.target == atoms.TARGETS { | ||
| let reader = self.wait_write_data.read(); | ||
| match reader { | ||
| Ok(data_list) => { | ||
| let mut targets = Vec::with_capacity(10); | ||
| targets.push(atoms.TARGETS); | ||
| targets.push(atoms.SAVE_TARGETS); | ||
| if !data_list.is_empty() { | ||
| data_list.iter().for_each(|data| { | ||
| targets.push(data.format); | ||
| }); | ||
| } | ||
| ctx.conn.change_property32( | ||
| PropMode::REPLACE, | ||
| event.requestor, | ||
| event.property, | ||
| AtomEnum::ATOM, | ||
| &targets, | ||
| )?; | ||
| success = true; | ||
| } | ||
| Err(_) => return Err("Failed to read clipboard data".into()), | ||
| } | ||
| } else { | ||
| let reader = self.wait_write_data.read(); | ||
| match reader { | ||
| Ok(data_list) => { | ||
| success = match data_list.iter().find(|d| d.format == event.target) { | ||
| Some(data) => { | ||
| ctx.conn.change_property8( | ||
| PropMode::REPLACE, | ||
| event.requestor, | ||
| event.property, | ||
| event.target, | ||
| &data.data, | ||
| )?; | ||
| true | ||
| } | ||
| None => false, | ||
| }; | ||
| } | ||
| Err(_) => return Err("Failed to read clipboard data".into()), | ||
| } | ||
| } | ||
| // on failure, we notify the requester of it | ||
| let property = if success { | ||
| event.property | ||
| } else { | ||
| AtomEnum::NONE.into() | ||
| }; | ||
| // tell the requester that we finished sending data | ||
| ctx.conn.send_event( | ||
| false, | ||
| event.requestor, | ||
| EventMask::NO_EVENT, | ||
| SelectionNotifyEvent { | ||
| response_type: SELECTION_NOTIFY_EVENT, | ||
| sequence: event.sequence, | ||
| time: event.time, | ||
| requestor: event.requestor, | ||
| selection: event.selection, | ||
| target: event.target, | ||
| property, | ||
| }, | ||
| )?; | ||
| ctx.conn.flush()?; | ||
| Ok(()) | ||
| } | ||
| pub fn process_event( | ||
| &self, | ||
| buff: &mut Vec<u8>, | ||
| selection: Atom, | ||
| target: Atom, | ||
| property: Atom, | ||
| timeout: Option<Duration>, | ||
| sequence_number: u64, | ||
| ) -> Result<()> { | ||
| let mut is_incr = false; | ||
| let start_time = if timeout.is_some() { | ||
| Some(Instant::now()) | ||
| } else { | ||
| None | ||
| }; | ||
| let ctx = &self.server; | ||
| let atoms = ctx.atoms; | ||
| loop { | ||
| if timeout | ||
| .into_iter() | ||
| .zip(start_time) | ||
| .next() | ||
| .map(|(timeout, time)| (Instant::now() - time) >= timeout) | ||
| .unwrap_or(false) | ||
| { | ||
| return Err("Timeout while waiting for clipboard data".into()); | ||
| } | ||
| let (event, seq) = match ctx.conn.poll_for_event_with_sequence()? { | ||
| Some(event) => event, | ||
| None => { | ||
| thread::park_timeout(Duration::from_millis(50)); | ||
| continue; | ||
| } | ||
| }; | ||
| if seq < sequence_number { | ||
| continue; | ||
| } | ||
| match event { | ||
| Event::SelectionNotify(event) => { | ||
| if event.selection != selection { | ||
| continue; | ||
| }; | ||
| let target_type = { | ||
| if target == atoms.TARGETS { | ||
| atoms.ATOM | ||
| } else { | ||
| target | ||
| } | ||
| }; | ||
| let reply = ctx | ||
| .conn | ||
| .get_property( | ||
| false, | ||
| event.requestor, | ||
| event.property, | ||
| target_type, | ||
| buff.len() as u32, | ||
| u32::MAX, | ||
| )? | ||
| .reply()?; | ||
| if reply.type_ == atoms.INCR { | ||
| if let Some(mut value) = reply.value32() { | ||
| if let Some(size) = value.next() { | ||
| buff.reserve(size as usize); | ||
| } | ||
| } | ||
| ctx.conn.delete_property(ctx.win_id, property)?.check()?; | ||
| is_incr = true; | ||
| continue; | ||
| } else if reply.type_ != target && reply.type_ != atoms.ATOM { | ||
| return Err("Clipboard data type mismatch".into()); | ||
| } | ||
| buff.extend_from_slice(&reply.value); | ||
| break; | ||
| } | ||
| Event::PropertyNotify(event) if is_incr => { | ||
| if event.state != Property::NEW_VALUE { | ||
| continue; | ||
| }; | ||
| let cookie = ctx | ||
| .conn | ||
| .get_property(false, ctx.win_id, property, AtomEnum::ATOM, 0, 0)?; | ||
| let length = cookie.reply()?.bytes_after; | ||
| let cookie = | ||
| ctx | ||
| .conn | ||
| .get_property(true, ctx.win_id, property, AtomEnum::NONE, 0, length)?; | ||
| let reply = cookie.reply()?; | ||
| if reply.type_ != target { | ||
| continue; | ||
| }; | ||
| let value = reply.value; | ||
| if !value.is_empty() { | ||
| buff.extend_from_slice(&value); | ||
| } else { | ||
| break; | ||
| } | ||
| } | ||
| _ => (), | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
| } | ||
| impl ClipboardContext { | ||
| pub fn new() -> Result<Self> { | ||
| Self::new_with_options(ClipboardContextX11Options { | ||
| read_timeout: Some(Duration::from_millis(DEFAULT_READ_TIMEOUT)), | ||
| }) | ||
| } | ||
| pub fn new_with_options(options: ClipboardContextX11Options) -> Result<Self> { | ||
| // build connection to X server | ||
| let ctx = InnerContext::new()?; | ||
| let ctx_arc = Arc::new(ctx); | ||
| let ctx_clone = ctx_arc.clone(); | ||
| thread::spawn(move || { | ||
| let res = process_server_req(&ctx_clone); | ||
| if let Err(e) = res { | ||
| println!("process_server_req error: {e:?}"); | ||
| } | ||
| }); | ||
| Ok(Self { | ||
| inner: ctx_arc, | ||
| read_timeout: options.read_timeout, | ||
| }) | ||
| } | ||
| fn read(&self, format: &Atom) -> Result<Vec<u8>> { | ||
| let ctx = &self.inner.server; | ||
| let atoms = ctx.atoms; | ||
| let clipboard = atoms.CLIPBOARD; | ||
| let win_id = ctx.win_id; | ||
| let cookie = | ||
| ctx | ||
| .conn | ||
| .convert_selection(win_id, clipboard, *format, atoms.PROPERTY, CURRENT_TIME)?; | ||
| let sequence_num = cookie.sequence_number(); | ||
| cookie.check()?; | ||
| let mut buff = Vec::new(); | ||
| self.inner.process_event( | ||
| &mut buff, | ||
| clipboard, | ||
| *format, | ||
| atoms.PROPERTY, | ||
| self.read_timeout, | ||
| sequence_num, | ||
| )?; | ||
| ctx.conn.delete_property(win_id, atoms.PROPERTY)?.check()?; | ||
| Ok(buff) | ||
| } | ||
| fn write(&self, data: Vec<ClipboardData>) -> Result<()> { | ||
| let writer = self.inner.wait_write_data.write(); | ||
| match writer { | ||
| Ok(mut writer) => { | ||
| writer.clear(); | ||
| writer.extend(data); | ||
| } | ||
| Err(_) => return Err("Failed to write clipboard data".into()), | ||
| } | ||
| let ctx = &self.inner.server_for_write; | ||
| let atoms = ctx.atoms; | ||
| let win_id = ctx.win_id; | ||
| let clipboard = atoms.CLIPBOARD; | ||
| ctx | ||
| .conn | ||
| .set_selection_owner(win_id, clipboard, CURRENT_TIME)? | ||
| .check()?; | ||
| if ctx | ||
| .conn | ||
| .get_selection_owner(clipboard)? | ||
| .reply() | ||
| .map(|reply| reply.owner == win_id) | ||
| .unwrap_or(false) | ||
| { | ||
| Ok(()) | ||
| } else { | ||
| Err("Failed to take ownership of the clipboard".into()) | ||
| } | ||
| } | ||
| } | ||
| fn process_server_req(context: &InnerContext) -> Result<()> { | ||
| let atoms = context.server_for_write.atoms; | ||
| loop { | ||
| match context | ||
| .server_for_write | ||
| .conn | ||
| .wait_for_event() | ||
| .map_err(|e| format!("wait_for_event error: {e:?}"))? | ||
| { | ||
| Event::DestroyNotify(_) => { | ||
| // This window is being destroyed. | ||
| println!("Clipboard server window is being destroyed x_x"); | ||
| break; | ||
| } | ||
| Event::SelectionClear(event) => { | ||
| // Someone else has new content in the clipboard, so it is | ||
| // notifying us that we should delete our data now. | ||
| println!("Somebody else owns the clipboard now"); | ||
| if event.selection == atoms.CLIPBOARD { | ||
| // Clear the clipboard contents | ||
| context | ||
| .wait_write_data | ||
| .write() | ||
| .map(|mut writer| writer.clear()) | ||
| .map_err(|e| format!("write clipboard data error: {e:?}"))?; | ||
| } | ||
| } | ||
| Event::SelectionRequest(event) => { | ||
| // Someone is requesting the clipboard content from us. | ||
| context | ||
| .handle_selection_request(event) | ||
| .map_err(|e| format!("handle_selection_request error: {e:?}"))?; | ||
| } | ||
| Event::SelectionNotify(event) => { | ||
| // We've requested the clipboard content and this is the answer. | ||
| // Considering that this thread is not responsible for reading | ||
| // clipboard contents, this must come from the clipboard manager | ||
| // signaling that the data was handed over successfully. | ||
| if event.selection != atoms.CLIPBOARD_MANAGER { | ||
| println!("Received a `SelectionNotify` from a selection other than the CLIPBOARD_MANAGER. This is unexpected in this thread."); | ||
| continue; | ||
| } | ||
| } | ||
| _event => { | ||
| // May be useful for debugging but nothing else really. | ||
| // trace!("Received unwanted event: {:?}", event); | ||
| } | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
| impl Clipboard for ClipboardContext { | ||
| //https://source.chromium.org/chromium/chromium/src/+/main:ui/base/x/x11_clipboard_helper.cc;l=224;drc=4cc063ac39c4a0d1f6011421b259a9715bb16de1;bpv=0;bpt=1 | ||
| fn available_formats(&self) -> Result<Vec<String>> { | ||
| let ctx = &self.inner.server; | ||
| let atoms = ctx.atoms; | ||
| self.read(&atoms.TARGETS).map(|data| { | ||
| let mut formats = Vec::new(); | ||
| // 解析原子标识符列表 | ||
| let atom_list: Vec<Atom> = parse_atom_list(&data); | ||
| for atom in atom_list { | ||
| if self.inner.ignore_formats.contains(&atom) { | ||
| continue; | ||
| } | ||
| let atom_name = ctx.get_atom_name(atom).unwrap_or("Unknown".to_string()); | ||
| formats.push(atom_name); | ||
| } | ||
| formats | ||
| }) | ||
| } | ||
| fn has(&self, format: crate::ContentFormat) -> bool { | ||
| let ctx = &self.inner.server; | ||
| let atoms = ctx.atoms; | ||
| let atom_list = self.read(&atoms.TARGETS).map(|data| parse_atom_list(&data)); | ||
| match atom_list { | ||
| Ok(formats) => match format { | ||
| ContentFormat::Text => formats.contains(&atoms.UTF8_STRING), | ||
| ContentFormat::Rtf => formats.contains(&atoms.RTF), | ||
| ContentFormat::Html => formats.contains(&atoms.HTML), | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => formats.contains(&atoms.PNG_MIME), | ||
| ContentFormat::Files => formats.contains(&atoms.FILE_LIST), | ||
| ContentFormat::Other(format_name) => { | ||
| let atom = ctx.get_atom(format_name.as_str()); | ||
| match atom { | ||
| Ok(atom) => formats.contains(&atom), | ||
| Err(_) => false, | ||
| } | ||
| } | ||
| }, | ||
| Err(_) => false, | ||
| } | ||
| } | ||
| fn clear(&self) -> Result<()> { | ||
| self.write(vec![]) | ||
| } | ||
| fn get_buffer(&self, format: &str) -> Result<Vec<u8>> { | ||
| let atom = self.inner.server.get_atom(format); | ||
| match atom { | ||
| Ok(atom) => self.read(&atom), | ||
| Err(_) => Err("Invalid format".into()), | ||
| } | ||
| } | ||
| fn get_text(&self) -> Result<String> { | ||
| let atoms = self.inner.server.atoms; | ||
| let text_data = self.read(&atoms.UTF8_STRING); | ||
| text_data.map_or_else( | ||
| |_| Ok("".to_string()), | ||
| |data| Ok(String::from_utf8_lossy(&data).to_string()), | ||
| ) | ||
| } | ||
| fn get_rich_text(&self) -> Result<String> { | ||
| let atoms = self.inner.server.atoms; | ||
| let rtf_data = self.read(&atoms.RTF); | ||
| rtf_data.map_or_else( | ||
| |_| Ok("".to_string()), | ||
| |data| Ok(String::from_utf8_lossy(&data).to_string()), | ||
| ) | ||
| } | ||
| fn get_html(&self) -> Result<String> { | ||
| let atoms = self.inner.server.atoms; | ||
| let html_data = self.read(&atoms.HTML); | ||
| html_data.map_or_else( | ||
| |_| Ok("".to_string()), | ||
| |data| Ok(String::from_utf8_lossy(&data).to_string()), | ||
| ) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn get_image(&self) -> Result<crate::RustImageData> { | ||
| let atoms = self.inner.server.atoms; | ||
| let image_bytes = self.read(&atoms.PNG_MIME); | ||
| match image_bytes { | ||
| Ok(bytes) => { | ||
| let image = RustImageData::from_bytes(&bytes); | ||
| match image { | ||
| Ok(image) => Ok(image), | ||
| Err(_) => Err("Invalid image data".into()), | ||
| } | ||
| } | ||
| Err(_) => Err("No image data found".into()), | ||
| } | ||
| } | ||
| fn get_files(&self) -> Result<Vec<String>> { | ||
| let atoms = self.inner.server.atoms; | ||
| let file_list_data = self.read(&atoms.FILE_LIST); | ||
| file_list_data.map_or_else( | ||
| |_| Ok(vec![]), | ||
| |data| { | ||
| let file_list_str = String::from_utf8_lossy(&data).to_string(); | ||
| let mut list = Vec::new(); | ||
| for line in file_list_str.lines() { | ||
| if !line.starts_with(FILE_PATH_PREFIX) { | ||
| continue; | ||
| } | ||
| list.push(line.to_string()) | ||
| } | ||
| Ok(list) | ||
| }, | ||
| ) | ||
| } | ||
| fn get(&self, formats: &[ContentFormat]) -> Result<Vec<ClipboardContent>> { | ||
| let mut contents = Vec::new(); | ||
| for format in formats { | ||
| match format { | ||
| ContentFormat::Text => match self.get_text() { | ||
| Ok(text) => contents.push(ClipboardContent::Text(text)), | ||
| Err(_) => continue, | ||
| }, | ||
| ContentFormat::Rtf => match self.get_rich_text() { | ||
| Ok(rtf) => contents.push(ClipboardContent::Rtf(rtf)), | ||
| Err(_) => continue, | ||
| }, | ||
| ContentFormat::Html => match self.get_html() { | ||
| Ok(html) => contents.push(ClipboardContent::Html(html)), | ||
| Err(_) => continue, | ||
| }, | ||
| #[cfg(feature = "image")] | ||
| ContentFormat::Image => match self.get_image() { | ||
| Ok(image) => contents.push(ClipboardContent::Image(image)), | ||
| Err(_) => continue, | ||
| }, | ||
| ContentFormat::Files => match self.get_files() { | ||
| Ok(files) => contents.push(ClipboardContent::Files(files)), | ||
| Err(_) => continue, | ||
| }, | ||
| ContentFormat::Other(format_name) => match self.get_buffer(format_name) { | ||
| Ok(buffer) => contents.push(ClipboardContent::Other(format_name.clone(), buffer)), | ||
| Err(_) => continue, | ||
| }, | ||
| } | ||
| } | ||
| Ok(contents) | ||
| } | ||
| fn set_buffer(&self, format: &str, buffer: Vec<u8>) -> Result<()> { | ||
| let atom = self.inner.server_for_write.get_atom(format)?; | ||
| let data = ClipboardData { | ||
| format: atom, | ||
| data: buffer, | ||
| }; | ||
| self.write(vec![data]) | ||
| } | ||
| fn set_text(&self, text: String) -> Result<()> { | ||
| let atoms = self.inner.server_for_write.atoms; | ||
| let text_bytes = text.as_bytes().to_vec(); | ||
| let data = ClipboardData { | ||
| format: atoms.UTF8_STRING, | ||
| data: text_bytes, | ||
| }; | ||
| self.write(vec![data]) | ||
| } | ||
| fn set_rich_text(&self, text: String) -> Result<()> { | ||
| let atoms = self.inner.server_for_write.atoms; | ||
| let text_bytes = text.as_bytes().to_vec(); | ||
| let data = ClipboardData { | ||
| format: atoms.RTF, | ||
| data: text_bytes, | ||
| }; | ||
| self.write(vec![data]) | ||
| } | ||
| fn set_html(&self, html: String) -> Result<()> { | ||
| let atoms = self.inner.server_for_write.atoms; | ||
| let html_bytes = html.as_bytes().to_vec(); | ||
| let data = ClipboardData { | ||
| format: atoms.HTML, | ||
| data: html_bytes, | ||
| }; | ||
| self.write(vec![data]) | ||
| } | ||
| #[cfg(feature = "image")] | ||
| fn set_image(&self, image: RustImageData) -> Result<()> { | ||
| let atoms = self.inner.server_for_write.atoms; | ||
| let image_png = image.to_png()?; | ||
| let data = ClipboardData { | ||
| format: atoms.PNG_MIME, | ||
| data: image_png.get_bytes().to_vec(), | ||
| }; | ||
| self.write(vec![data]) | ||
| } | ||
| fn set_files(&self, files: Vec<String>) -> Result<()> { | ||
| let atoms = self.inner.server_for_write.atoms; | ||
| let data = file_uri_list_to_clipboard_data(files, atoms); | ||
| self.write(data) | ||
| } | ||
| fn set(&self, contents: Vec<ClipboardContent>) -> Result<()> { | ||
| let mut data = Vec::new(); | ||
| let atoms = self.inner.server_for_write.atoms; | ||
| for content in contents { | ||
| match content { | ||
| ClipboardContent::Text(text) => { | ||
| data.push(ClipboardData { | ||
| format: atoms.UTF8_STRING, | ||
| data: text.as_bytes().to_vec(), | ||
| }); | ||
| } | ||
| ClipboardContent::Rtf(rtf) => { | ||
| data.push(ClipboardData { | ||
| format: atoms.RTF, | ||
| data: rtf.as_bytes().to_vec(), | ||
| }); | ||
| } | ||
| ClipboardContent::Html(html) => { | ||
| data.push(ClipboardData { | ||
| format: atoms.HTML, | ||
| data: html.as_bytes().to_vec(), | ||
| }); | ||
| } | ||
| #[cfg(feature = "image")] | ||
| ClipboardContent::Image(image) => { | ||
| let image_png = image.to_png()?; | ||
| data.push(ClipboardData { | ||
| format: atoms.PNG_MIME, | ||
| data: image_png.get_bytes().to_vec(), | ||
| }); | ||
| } | ||
| ClipboardContent::Files(files) => { | ||
| let data_arr = file_uri_list_to_clipboard_data(files, atoms); | ||
| data.extend(data_arr); | ||
| } | ||
| ClipboardContent::Other(format_name, buffer) => { | ||
| let atom = self.inner.server_for_write.get_atom(&format_name)?; | ||
| data.push(ClipboardData { | ||
| format: atom, | ||
| data: buffer, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| self.write(data) | ||
| } | ||
| } | ||
| pub struct ClipboardWatcherContext<T: ClipboardHandler> { | ||
| pub(crate) handlers: Vec<T>, | ||
| pub(crate) stop_signal: Sender<()>, | ||
| stop_receiver: Receiver<()>, | ||
| } | ||
| unsafe impl<T: ClipboardHandler> Send for ClipboardWatcherContext<T> {} | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub fn new() -> Result<Self> { | ||
| let (tx, rx) = mpsc::channel(); | ||
| Ok(Self { | ||
| handlers: Vec::new(), | ||
| stop_signal: tx, | ||
| stop_receiver: rx, | ||
| }) | ||
| } | ||
| } | ||
| impl<T: ClipboardHandler> ClipboardWatcherContext<T> { | ||
| pub(crate) fn start_watch_inner(&mut self) { | ||
| let watch_server = XServerContext::new().expect("Failed to create X server context"); | ||
| let screen = watch_server | ||
| .conn | ||
| .setup() | ||
| .roots | ||
| .get(watch_server._screen) | ||
| .expect("Failed to get screen"); | ||
| xfixes::query_version(&watch_server.conn, 5, 0) | ||
| .expect("Failed to query version xfixes is not available"); | ||
| let cookie = xfixes::select_selection_input( | ||
| &watch_server.conn, | ||
| screen.root, | ||
| watch_server.atoms.CLIPBOARD, | ||
| xfixes::SelectionEventMask::SET_SELECTION_OWNER | ||
| | xfixes::SelectionEventMask::SELECTION_CLIENT_CLOSE | ||
| | xfixes::SelectionEventMask::SELECTION_WINDOW_DESTROY, | ||
| ) | ||
| .expect("Failed to select selection input"); | ||
| cookie.check().unwrap(); | ||
| loop { | ||
| if self | ||
| .stop_receiver | ||
| .recv_timeout(Duration::from_millis(500)) | ||
| .is_ok() | ||
| { | ||
| break; | ||
| } | ||
| let event = match watch_server | ||
| .conn | ||
| .poll_for_event() | ||
| .expect("Failed to poll for event") | ||
| { | ||
| Some(event) => event, | ||
| None => { | ||
| continue; | ||
| } | ||
| }; | ||
| if let Event::XfixesSelectionNotify(_) = event { | ||
| self | ||
| .handlers | ||
| .iter_mut() | ||
| .for_each(|handler| handler.on_clipboard_change()); | ||
| } | ||
| } | ||
| } | ||
| pub(crate) fn get_shutdown_channel(&self) -> WatcherShutdown { | ||
| WatcherShutdown { | ||
| sender: self.stop_signal.clone(), | ||
| } | ||
| } | ||
| } | ||
| pub struct WatcherShutdown { | ||
| pub(crate) sender: Sender<()>, | ||
| } | ||
| impl Drop for WatcherShutdown { | ||
| fn drop(&mut self) { | ||
| let _ = self.sender.send(()); | ||
| } | ||
| } | ||
| struct XServerContext { | ||
| conn: RustConnection, | ||
| win_id: u32, | ||
| _screen: usize, | ||
| atoms: Atoms, | ||
| } | ||
| impl XServerContext { | ||
| fn new() -> Result<Self> { | ||
| let (conn, screen) = x11rb::connect(None)?; | ||
| let win_id = conn.generate_id()?; | ||
| { | ||
| let screen = conn.setup().roots.get(screen).unwrap(); | ||
| conn | ||
| .create_window( | ||
| COPY_DEPTH_FROM_PARENT, | ||
| win_id, | ||
| screen.root, | ||
| 0, | ||
| 0, | ||
| 1, | ||
| 1, | ||
| 0, | ||
| WindowClass::INPUT_OUTPUT, | ||
| screen.root_visual, | ||
| &CreateWindowAux::new() | ||
| .event_mask(EventMask::STRUCTURE_NOTIFY | EventMask::PROPERTY_CHANGE), | ||
| )? | ||
| .check()?; | ||
| } | ||
| let atoms = Atoms::new(&conn)?.reply()?; | ||
| Ok(Self { | ||
| conn, | ||
| win_id, | ||
| _screen: screen, | ||
| atoms, | ||
| }) | ||
| } | ||
| fn get_atom(&self, format: &str) -> Result<Atom> { | ||
| let cookie = self.conn.intern_atom(false, format.as_bytes())?; | ||
| Ok(cookie.reply()?.atom) | ||
| } | ||
| fn get_atom_name(&self, atom: Atom) -> Result<String> { | ||
| let cookie = self.conn.get_atom_name(atom)?; | ||
| Ok(String::from_utf8_lossy(&cookie.reply()?.name).to_string()) | ||
| } | ||
| } | ||
| // 解析原子标识符列表 | ||
| fn parse_atom_list(data: &[u8]) -> Vec<Atom> { | ||
| data | ||
| .chunks(4) | ||
| .map(|chunk| { | ||
| let mut bytes = [0u8; 4]; | ||
| bytes.copy_from_slice(chunk); | ||
| u32::from_ne_bytes(bytes) | ||
| }) | ||
| .collect() | ||
| } | ||
| fn file_uri_list_to_clipboard_data(file_list: Vec<String>, atoms: Atoms) -> Vec<ClipboardData> { | ||
| let uri_list: Vec<String> = file_list | ||
| .iter() | ||
| .map(|f| { | ||
| if f.starts_with(FILE_PATH_PREFIX) { | ||
| f.to_owned() | ||
| } else { | ||
| format!("{FILE_PATH_PREFIX}{f}") | ||
| } | ||
| }) | ||
| .collect(); | ||
| // 再构造一个 /home/xxx/xxx 这样的路径 | ||
| let uri_str_list: Vec<String> = file_list | ||
| .iter() | ||
| .map(|f| { | ||
| if let Some(stripped) = f.strip_prefix(FILE_PATH_PREFIX) { | ||
| stripped.to_owned() | ||
| } else { | ||
| f.to_owned() | ||
| } | ||
| }) | ||
| .collect(); | ||
| let data_text_plain = uri_str_list.join("\r\n"); | ||
| let data_text_utf8 = uri_str_list.join("\n"); | ||
| let data_text_uri_list = uri_list.join("\r\n"); | ||
| let data_gnome_copied_files = ["copy\n", uri_list.join("\n").as_str()].concat(); | ||
| vec![ | ||
| ClipboardData { | ||
| format: atoms.TEXT_MIME_UNKNOWN, | ||
| data: data_text_plain.as_bytes().to_vec(), | ||
| }, | ||
| ClipboardData { | ||
| format: atoms.UTF8_MIME_0, | ||
| data: data_text_plain.as_bytes().to_vec(), | ||
| }, | ||
| ClipboardData { | ||
| format: atoms.STRING, | ||
| data: data_text_utf8.as_bytes().to_vec(), | ||
| }, | ||
| ClipboardData { | ||
| format: atoms.TEXT, | ||
| data: data_text_utf8.as_bytes().to_vec(), | ||
| }, | ||
| ClipboardData { | ||
| format: atoms.UTF8_STRING, | ||
| data: data_text_utf8.as_bytes().to_vec(), | ||
| }, | ||
| ClipboardData { | ||
| format: atoms.FILE_LIST, | ||
| data: data_text_uri_list.as_bytes().to_vec(), | ||
| }, | ||
| ClipboardData { | ||
| format: atoms.GNOME_COPY_FILES, | ||
| data: data_gnome_copied_files.as_bytes().to_vec(), | ||
| }, | ||
| ClipboardData { | ||
| format: atoms.NAUTILUS_FILE_LIST, | ||
| data: data_gnome_copied_files.as_bytes().to_vec(), | ||
| }, | ||
| ] | ||
| } |
+21
-2
@@ -9,8 +9,12 @@ [package] | ||
| [features] | ||
| default = ["image"] | ||
| image = ["dep:image"] | ||
| wayland = ["dep:wl-clipboard-rs"] | ||
| [dependencies] | ||
| base64 = "0.22.0" | ||
| clipboard-rs = "0.2.4" | ||
| futures = "0.3.30" | ||
| # Pin image to version compatible with older Rust in CI Docker images | ||
| image = "=0.25.5" | ||
| image = { version = "=0.25.5", features = ["png", "jpeg"], optional = true, default-features = false } | ||
| # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix | ||
@@ -21,2 +25,17 @@ napi = { version = "2.12.2", default-features = false, features = ["napi4", "tokio_rt"] } | ||
| [target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="ios", target_os="emscripten"))))'.dependencies] | ||
| wl-clipboard-rs = { version = "0.9", optional = true } | ||
| x11rb = { version = "0.13.2", features = ["xfixes"] } | ||
| [target.'cfg(target_os = "macos")'.dependencies] | ||
| image = { version = "=0.25.5", features = ["tiff", "png", "jpeg"], optional = true, default-features = false } | ||
| objc2 = "0.6.3" | ||
| objc2-app-kit = { version = "0.3.2", features = ["NSPasteboard", "NSPasteboardItem", "NSImage"] } | ||
| objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSString", "NSEnumerator"] } | ||
| [target.'cfg(target_os = "windows")'.dependencies] | ||
| clipboard-win = { version = "5.4.1", features = ["monitor"] } | ||
| image = { version = "=0.25.5", features = ["bmp", "png", "jpeg"], optional = true, default-features = false } | ||
| windows = { version = "0.59.0", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_DataExchange"] } | ||
| [build-dependencies] | ||
@@ -23,0 +42,0 @@ # Pin to version that works with older Rust in Docker images |
+11
-11
| { | ||
| "name": "@mariozechner/clipboard", | ||
| "version": "0.3.5", | ||
| "version": "0.3.6", | ||
| "main": "index.js", | ||
@@ -45,13 +45,13 @@ "types": "index.d.ts", | ||
| "optionalDependencies": { | ||
| "@mariozechner/clipboard-darwin-arm64": "0.3.2", | ||
| "@mariozechner/clipboard-darwin-universal": "0.3.2", | ||
| "@mariozechner/clipboard-darwin-x64": "0.3.2", | ||
| "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", | ||
| "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", | ||
| "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", | ||
| "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", | ||
| "@mariozechner/clipboard-linux-x64-musl": "0.3.2", | ||
| "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", | ||
| "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" | ||
| "@mariozechner/clipboard-darwin-arm64": "0.3.6", | ||
| "@mariozechner/clipboard-darwin-universal": "0.3.6", | ||
| "@mariozechner/clipboard-darwin-x64": "0.3.6", | ||
| "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", | ||
| "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", | ||
| "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", | ||
| "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", | ||
| "@mariozechner/clipboard-linux-x64-musl": "0.3.6", | ||
| "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", | ||
| "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" | ||
| } | ||
| } |
+2
-0
@@ -59,1 +59,3 @@ # @mariozechner/clipboard | ||
| Then `git push --follow-tags` to push the changes and tags to GitHub. GitHub Action will automatically build and publish. | ||
| The GitHub Actions `NPM_TOKEN` repository secret is a granular npm token with publish access to `@mariozechner/clipboard` and all `@mariozechner/clipboard-*` platform packages. npm granular access tokens expire after 90 days, so this secret must be rotated before expiry. |
+3
-0
| #![deny(clippy::all)] | ||
| use base64::{engine::general_purpose, Engine as _}; | ||
| #[allow(dead_code, unused_imports)] | ||
| mod clipboard_rs; | ||
| use clipboard_rs::{ | ||
@@ -4,0 +7,0 @@ common::RustImage, Clipboard, ClipboardContext, ClipboardHandler, ClipboardWatcher, |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
126317
526.04%17
88.89%61
3.39%