diff --git a/Cargo.toml b/Cargo.toml index 6c450f4..fa2e7ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,12 @@ rust-version = "1.65.0" libc = "0.2.149" sctk = { package = "smithay-client-toolkit", version = "0.18.0", default-features = false, features = ["calloop"] } wayland-backend = { version = "0.3.0", default_features = false, features = ["client_system"] } +thiserror = "1.0.57" [dev-dependencies] sctk = { package = "smithay-client-toolkit", version = "0.18.0", default-features = false, features = ["calloop", "xkbcommon"] } +url = "2.5.0" +dirs = "5.0.1" [features] default = ["dlopen"] diff --git a/examples/clipboard.rs b/examples/clipboard.rs index 583e99d..bc1c9dd 100644 --- a/examples/clipboard.rs +++ b/examples/clipboard.rs @@ -2,6 +2,9 @@ // application. For more details on what is going on, consult the // `smithay-client-toolkit` examples. +use std::borrow::Cow; +use std::str::{FromStr, Utf8Error}; + use sctk::compositor::{CompositorHandler, CompositorState}; use sctk::output::{OutputHandler, OutputState}; use sctk::reexports::calloop::{EventLoop, LoopHandle}; @@ -21,7 +24,10 @@ use sctk::{ delegate_compositor, delegate_keyboard, delegate_output, delegate_registry, delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window, registry_handlers, }; +use smithay_clipboard::mime::{AllowedMimeTypes, AsMimeTypes, MimeType}; use smithay_clipboard::Clipboard; +use thiserror::Error; +use url::Url; const MIN_DIM_SIZE: usize = 256; @@ -277,27 +283,53 @@ impl KeyboardHandler for SimpleWindow { ) { match event.utf8.as_deref() { // Paste primary. - Some("P") => match self.clipboard.load_primary() { + Some("P") => match self.clipboard.load_primary_text() { Ok(contents) => println!("Paste from primary clipboard: {contents}"), Err(err) => eprintln!("Error loading from primary clipboard: {err}"), }, // Paste clipboard. - Some("p") => match self.clipboard.load() { + Some("p") => match self.clipboard.load_text() { Ok(contents) => println!("Paste from clipboard: {contents}"), Err(err) => eprintln!("Error loading from clipboard: {err}"), }, // Copy primary. Some("C") => { let to_store = "Copy primary"; - self.clipboard.store_primary(to_store); + self.clipboard.store_primary_text(to_store); println!("Copied string into primary clipboard: {}", to_store); }, // Copy clipboard. Some("c") => { let to_store = "Copy"; - self.clipboard.store(to_store); + self.clipboard.store_text(to_store); println!("Copied string into clipboard: {}", to_store); }, + // Copy URI to primary clipboard. + Some("F") => { + let home = Uri::home(); + println!("Copied home dir into primary clipboard: {}", home.0); + self.clipboard.store_primary(home); + }, + // Copy URI to clipboard. + Some("f") => { + let home = Uri::home(); + println!("Copied home dir into clipboard: {}", home.0); + self.clipboard.store(home); + }, + // Read URI from clipboard + Some("o") => match self.clipboard.load::() { + Ok(uri) => { + println!("URI from clipboard: {}", uri.0); + }, + Err(err) => eprintln!("Error loading from clipboard: {err}"), + }, + // Read URI from clipboard + Some("O") => match self.clipboard.load_primary::() { + Ok(uri) => { + println!("URI from primary clipboard: {}", uri.0); + }, + Err(err) => eprintln!("Error loading from clipboard: {err}"), + }, _ => (), } } @@ -382,6 +414,63 @@ impl SimpleWindow { } } +#[derive(Debug)] +pub struct Uri(Url); + +impl Uri { + pub fn home() -> Self { + let home = dirs::home_dir().unwrap(); + Uri(Url::from_file_path(home).unwrap()) + } +} + +impl AsMimeTypes for Uri { + fn available<'a>(&'a self) -> Cow<'static, [MimeType]> { + Self::allowed() + } + + fn as_bytes(&self, mime_type: &MimeType) -> Option> { + if mime_type == &Self::allowed()[0] { + Some(self.0.to_string().as_bytes().to_vec().into()) + } else { + None + } + } +} + +impl AllowedMimeTypes for Uri { + fn allowed() -> Cow<'static, [MimeType]> { + std::borrow::Cow::Borrowed(&[MimeType::Other(Cow::Borrowed("text/uri-list"))]) + } +} + +#[derive(Error, Debug)] +pub enum UriError { + #[error("Unsupported mime type")] + Unsupported, + #[error("Utf8 error")] + Utf8(Utf8Error), + #[error("URL parse error")] + Parse(url::ParseError), +} + +impl TryFrom<(Vec, MimeType)> for Uri { + type Error = UriError; + + fn try_from((data, mime): (Vec, MimeType)) -> Result { + if mime == Self::allowed()[0] { + std::str::from_utf8(&data) + .map_err(UriError::Utf8) + .and_then(|s| Url::from_str(s).map_err(UriError::Parse)) + .map(Uri) + } else { + Err(UriError::Unsupported) + } + } +} + +pub const URI_MIME_TYPE: &str = "text/uri-list"; + delegate_compositor!(SimpleWindow); delegate_output!(SimpleWindow); delegate_shm!(SimpleWindow); diff --git a/src/lib.rs b/src/lib.rs index 1884eb8..fba320a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,18 +8,22 @@ use std::ffi::c_void; use std::io::Result; use std::sync::mpsc::{self, Receiver}; +use mime::{AllowedMimeTypes, AsMimeTypes, MimeType}; use sctk::reexports::calloop::channel::{self, Sender}; use sctk::reexports::client::backend::Backend; use sctk::reexports::client::Connection; +use state::SelectionTarget; +use text::Text; -mod mime; +pub mod mime; mod state; +mod text; mod worker; /// Access to a Wayland clipboard. pub struct Clipboard { request_sender: Sender, - request_receiver: Receiver>, + request_receiver: Receiver, MimeType)>>, clipboard_thread: Option>, } @@ -46,14 +50,21 @@ impl Clipboard { Self { request_receiver, request_sender, clipboard_thread } } - /// Load clipboard data. - /// - /// Loads content from a clipboard on a last observed seat. - pub fn load(&self) -> Result { - let _ = self.request_sender.send(worker::Command::Load); + fn load_inner(&self, target: SelectionTarget) -> Result + where + , MimeType)>>::Error: std::error::Error + Send + Sync, + { + let _ = self.request_sender.send(worker::Command::Load(T::allowed().to_vec(), target)); if let Ok(reply) = self.request_receiver.recv() { - reply + match reply { + Ok((data, mime)) => { + T::try_from((data, mime)).map_err(|err| std::io::Error::other(err)) + }, + Err(err) => { + return Err(err); + }, + } } else { // The clipboard thread is dead, however we shouldn't crash downstream, so // propogating an error. @@ -61,35 +72,71 @@ impl Clipboard { } } - /// Store to a clipboard. + /// Load custom clipboard data. /// - /// Stores to a clipboard on a last observed seat. - pub fn store>(&self, text: T) { - let request = worker::Command::Store(text.into()); - let _ = self.request_sender.send(request); + /// Load the requested type from a clipboard on the last observed seat. + pub fn load(&self) -> Result + where + , MimeType)>>::Error: std::error::Error + Send + Sync, + { + self.load_inner(SelectionTarget::Clipboard) + } + + /// Load clipboard data. + /// + /// Loads content from a clipboard on a last observed seat. + pub fn load_text(&self) -> Result { + self.load::().map(|t| t.0) + } + + /// Load custom primary clipboard data. + /// + /// Load the requested type from a primary clipboard on the last observed seat. + pub fn load_primary(&self) -> Result + where + , MimeType)>>::Error: std::error::Error + Send + Sync, + { + self.load_inner(SelectionTarget::Primary) } /// Load primary clipboard data. /// /// Loads content from a primary clipboard on a last observed seat. - pub fn load_primary(&self) -> Result { - let _ = self.request_sender.send(worker::Command::LoadPrimary); + pub fn load_primary_text(&self) -> Result { + self.load_primary::().map(|t| t.0) + } - if let Ok(reply) = self.request_receiver.recv() { - reply - } else { - // The clipboard thread is dead, however we shouldn't crash downstream, so - // propogating an error. - Err(std::io::Error::new(std::io::ErrorKind::Other, "clipboard is dead.")) - } + fn store_inner(&self, data: T, target: SelectionTarget) { + let request = worker::Command::Store(Box::new(data), target); + let _ = self.request_sender.send(request); + } + + /// Store custom data to a clipboard. + /// + /// Stores data of the provided type to a clipboard on a last observed seat. + pub fn store(&self, data: T) { + self.store_inner(data, SelectionTarget::Clipboard); + } + + /// Store to a clipboard. + /// + /// Stores to a clipboard on a last observed seat. + pub fn store_text>(&self, text: T) { + self.store(Text(text.into())); + } + + /// Store custom data to a primary clipboard. + /// + /// Stores data of the provided type to a primary clipboard on a last observed seat. + pub fn store_primary(&self, data: T) { + self.store_inner(data, SelectionTarget::Primary); } /// Store to a primary clipboard. /// /// Stores to a primary clipboard on a last observed seat. - pub fn store_primary>(&self, text: T) { - let request = worker::Command::StorePrimary(text.into()); - let _ = self.request_sender.send(request); + pub fn store_primary_text>(&self, text: T) { + self.store_primary(Text(text.into())); } } diff --git a/src/mime.rs b/src/mime.rs index a934972..40dd707 100644 --- a/src/mime.rs +++ b/src/mime.rs @@ -1,10 +1,21 @@ +use std::borrow::Cow; +use thiserror::Error; + /// List of allowed mimes. -pub static ALLOWED_MIME_TYPES: [&str; 3] = +pub static ALLOWED_TEXT_MIME_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"]; +#[derive(Error, Debug)] +pub enum Error { + #[error("Unsupported mime type")] + Unsupported, +} + /// Mime type supported by clipboard. -#[derive(Clone, Copy, Eq, PartialEq, Debug)] +#[derive(Clone, Eq, PartialEq, Debug, Default)] +#[repr(u8)] pub enum MimeType { + #[default] /// text/plain;charset=utf-8 mime type. /// /// The primary mime type used by most clients @@ -18,6 +29,37 @@ pub enum MimeType { /// /// Fallback without charset parameter. TextPlain = 2, + /// Other mime type + Other(Cow<'static, str>), +} + +impl AsRef for MimeType { + fn as_ref(&self) -> &str { + match self { + MimeType::Other(s) => s.as_ref(), + m => &ALLOWED_TEXT_MIME_TYPES[m.discriminant() as usize], + } + } +} + +impl MimeType { + fn discriminant(&self) -> u8 { + unsafe { *(self as *const Self as *const u8) } + } +} + +/// Describes the mime types which are accepted +pub trait AllowedMimeTypes: TryFrom<(Vec, MimeType)> { + fn allowed() -> Cow<'static, [MimeType]>; +} + +/// Can be converted to data with the available mime types +pub trait AsMimeTypes { + /// Available mime types for this data + fn available<'a>(&'a self) -> Cow<'static, [MimeType]>; + + /// Data as a specific mime_type + fn as_bytes(&self, mime_type: &MimeType) -> Option>; } impl MimeType { @@ -25,26 +67,22 @@ impl MimeType { /// /// `find_allowed()` searches for mime type clipboard supports, if we have a /// match, returns `Some(MimeType)`, otherwise `None`. - pub fn find_allowed(offered_mime_types: &[String]) -> Option { - let mut fallback = None; - for offered_mime_type in offered_mime_types.iter() { - if offered_mime_type == ALLOWED_MIME_TYPES[Self::TextPlainUtf8 as usize] { - return Some(Self::TextPlainUtf8); - } else if offered_mime_type == ALLOWED_MIME_TYPES[Self::Utf8String as usize] { - return Some(Self::Utf8String); - } else if offered_mime_type == ALLOWED_MIME_TYPES[Self::TextPlain as usize] { - // Only use this mime type as a fallback. - fallback = Some(Self::TextPlain); - } - } - - fallback + pub fn find_allowed(offered_mime_types: &[String], allowed: &[Self]) -> Option { + allowed + .iter() + .find(|allowed| { + offered_mime_types.iter().any(|offered| offered.as_str() == allowed.as_ref()) + }) + .cloned() } } impl std::fmt::Display for MimeType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", ALLOWED_MIME_TYPES[*self as usize]) + match self { + MimeType::Other(m) => write!(f, "{}", m), + m => write!(f, "{}", ALLOWED_TEXT_MIME_TYPES[m.discriminant() as usize]), + } } } diff --git a/src/state.rs b/src/state.rs index 69921e9..e6ee702 100644 --- a/src/state.rs +++ b/src/state.rs @@ -36,12 +36,13 @@ use sctk::reexports::protocols::wp::primary_selection::zv1::client::{ }; use wayland_backend::client::ObjectId; -use crate::mime::{normalize_to_lf, MimeType, ALLOWED_MIME_TYPES}; +use crate::mime::{AsMimeTypes, MimeType}; +use crate::text::Text; pub struct State { pub primary_selection_manager_state: Option, pub data_device_manager_state: Option, - pub reply_tx: Sender>, + pub reply_tx: Sender, MimeType)>>, pub exit: bool, registry_state: RegistryState, @@ -55,10 +56,12 @@ pub struct State { queue_handle: QueueHandle, primary_sources: Vec, - primary_selection_content: Rc<[u8]>, + primary_selection_content: Box, + primary_selection_mime_types: Rc>, data_sources: Vec, - data_selection_content: Rc<[u8]>, + data_selection_content: Box, + data_selection_mime_types: Rc>, } impl State { @@ -67,7 +70,7 @@ impl State { globals: &GlobalList, queue_handle: &QueueHandle, loop_handle: LoopHandle<'static, Self>, - reply_tx: Sender>, + reply_tx: Sender, MimeType)>>, ) -> Option { let mut seats = HashMap::new(); @@ -87,8 +90,8 @@ impl State { Some(Self { registry_state: RegistryState::new(globals), - primary_selection_content: Rc::from([]), - data_selection_content: Rc::from([]), + primary_selection_content: Box::new(Text(String::new())), + data_selection_content: Box::new(Text(String::new())), queue_handle: queue_handle.clone(), primary_selection_manager_state, primary_sources: Vec::new(), @@ -100,13 +103,19 @@ impl State { seat_state, reply_tx, seats, + primary_selection_mime_types: Rc::new(Default::default()), + data_selection_mime_types: Rc::new(Default::default()), }) } /// Store selection for the given target. /// /// Selection source is only created when `Some(())` is returned. - pub fn store_selection(&mut self, ty: SelectionTarget, contents: String) -> Option<()> { + pub fn store_selection( + &mut self, + ty: SelectionTarget, + contents: Box, + ) -> Option<()> { let latest = self.latest_seat.as_ref()?; let seat = self.seats.get_mut(latest)?; @@ -114,22 +123,22 @@ impl State { return None; } - let contents = Rc::from(contents.into_bytes()); - match ty { SelectionTarget::Clipboard => { let mgr = self.data_device_manager_state.as_ref()?; + let mime_types = contents.available(); self.data_selection_content = contents; - let source = - mgr.create_copy_paste_source(&self.queue_handle, ALLOWED_MIME_TYPES.iter()); + let source = mgr.create_copy_paste_source(&self.queue_handle, mime_types.iter()); + self.data_selection_mime_types = Rc::new(mime_types); source.set_selection(seat.data_device.as_ref().unwrap(), seat.latest_serial); self.data_sources.push(source); }, SelectionTarget::Primary => { let mgr = self.primary_selection_manager_state.as_ref()?; + let mime_types = contents.available(); self.primary_selection_content = contents; - let source = - mgr.create_selection_source(&self.queue_handle, ALLOWED_MIME_TYPES.iter()); + let source = mgr.create_selection_source(&self.queue_handle, mime_types.iter()); + self.primary_selection_mime_types = Rc::new(mime_types); source.set_selection(seat.primary_device.as_ref().unwrap(), seat.latest_serial); self.primary_sources.push(source); }, @@ -138,8 +147,14 @@ impl State { Some(()) } + // pub fn load_selection_custom(&mut self, ty: SelectionTarget) -> Result<()> {} + /// Load selection for the given target. - pub fn load_selection(&mut self, ty: SelectionTarget) -> Result<()> { + pub fn load_selection( + &mut self, + ty: SelectionTarget, + allowed_mime_types: &[MimeType], + ) -> Result<()> { let latest = self .latest_seat .as_ref() @@ -153,7 +168,7 @@ impl State { return Err(Error::new(ErrorKind::Other, "client doesn't have focus")); } - let (read_pipe, mime_type) = match ty { + let (read_pipe, mut mime_type) = match ty { SelectionTarget::Clipboard => { let selection = seat .data_device @@ -161,8 +176,9 @@ impl State { .and_then(|data| data.data().selection_offer()) .ok_or_else(|| Error::new(ErrorKind::Other, "selection is empty"))?; - let mime_type = - selection.with_mime_types(MimeType::find_allowed).ok_or_else(|| { + let mime_type = selection + .with_mime_types(|offered| MimeType::find_allowed(offered, allowed_mime_types)) + .ok_or_else(|| { Error::new(ErrorKind::NotFound, "supported mime-type is not found") })?; @@ -183,8 +199,9 @@ impl State { .and_then(|data| data.data().selection_offer()) .ok_or_else(|| Error::new(ErrorKind::Other, "selection is empty"))?; - let mime_type = - selection.with_mime_types(MimeType::find_allowed).ok_or_else(|| { + let mime_type = selection + .with_mime_types(|offered| MimeType::find_allowed(offered, allowed_mime_types)) + .ok_or_else(|| { Error::new(ErrorKind::NotFound, "supported mime-type is not found") })?; @@ -204,26 +221,9 @@ impl State { loop { match file.read(&mut reader_buffer) { Ok(0) => { - let utf8 = String::from_utf8_lossy(&content); - let content = match utf8 { - Cow::Borrowed(_) => { - // Don't clone the read data. - let mut to_send = Vec::new(); - mem::swap(&mut content, &mut to_send); - String::from_utf8(to_send).unwrap() - }, - Cow::Owned(content) => content, - }; - - // Post-process the content according to mime type. - let content = match mime_type { - MimeType::TextPlainUtf8 | MimeType::TextPlain => { - normalize_to_lf(content) - }, - MimeType::Utf8String => content, - }; - - let _ = state.reply_tx.send(Ok(content)); + let _ = state + .reply_tx + .send(Ok((mem::take(&mut content), mem::take(&mut mime_type)))); break PostAction::Remove; }, Ok(n) => content.extend_from_slice(&reader_buffer[..n]), @@ -240,10 +240,15 @@ impl State { } fn send_request(&mut self, ty: SelectionTarget, write_pipe: WritePipe, mime: String) { - // We can only send strings, so don't do anything with the mime-type. - if MimeType::find_allowed(&[mime]).is_none() { + let Some(mime_type) = MimeType::find_allowed( + &[mime], + match ty { + SelectionTarget::Clipboard => &self.data_selection_mime_types, + SelectionTarget::Primary => &self.primary_selection_mime_types, + }, + ) else { return; - } + }; // Mark FD as non-blocking so we won't block ourselves. unsafe { @@ -255,8 +260,12 @@ impl State { // Don't access the content on the state directly, since it could change during // the send. let contents = match ty { - SelectionTarget::Clipboard => self.data_selection_content.clone(), - SelectionTarget::Primary => self.primary_selection_content.clone(), + SelectionTarget::Clipboard => self.data_selection_content.as_bytes(&mime_type), + SelectionTarget::Primary => self.primary_selection_content.as_bytes(&mime_type), + }; + + let Some(contents) = contents else { + return; }; let mut written = 0; diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..c3cec3a --- /dev/null +++ b/src/text.rs @@ -0,0 +1,46 @@ +use std::borrow::Cow; + +use crate::mime::{normalize_to_lf, AllowedMimeTypes, AsMimeTypes, Error, MimeType}; + +pub struct Text(pub String); + +impl TryFrom<(Vec, MimeType)> for Text { + type Error = Error; + + fn try_from((content, mime_type): (Vec, MimeType)) -> Result { + let utf8 = String::from_utf8_lossy(&content); + let content = match utf8 { + Cow::Borrowed(_) => String::from_utf8(content).unwrap(), + Cow::Owned(content) => content, + }; + + // Post-process the content according to mime type. + let content = match mime_type { + MimeType::TextPlainUtf8 | MimeType::TextPlain => normalize_to_lf(content), + MimeType::Utf8String => content, + MimeType::Other(_) => return Err(Error::Unsupported), + }; + Ok(Text(content)) + } +} + +impl AllowedMimeTypes for Text { + fn allowed() -> Cow<'static, [MimeType]> { + Cow::Borrowed(&[MimeType::TextPlainUtf8, MimeType::Utf8String, MimeType::TextPlain]) + } +} + +impl AsMimeTypes for Text { + fn available(&self) -> Cow<'static, [MimeType]> { + Self::allowed() + } + + fn as_bytes<'a>(&'a self, mime_type: &MimeType) -> Option> { + match mime_type { + MimeType::TextPlainUtf8 | MimeType::Utf8String | MimeType::TextPlain => { + Some(Cow::Owned(self.0.as_bytes().to_owned())) + }, + MimeType::Other(_) => None, + } + } +} diff --git a/src/worker.rs b/src/worker.rs index 49885ca..8b8fcca 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -7,6 +7,7 @@ use sctk::reexports::calloop_wayland_source::WaylandSource; use sctk::reexports::client::globals::registry_queue_init; use sctk::reexports::client::Connection; +use crate::mime::{AsMimeTypes, MimeType}; use crate::state::{SelectionTarget, State}; /// Spawn a clipboard worker, which dispatches its own `EventQueue` and handles @@ -15,7 +16,7 @@ pub fn spawn( name: String, display: Connection, rx_chan: Channel, - worker_replier: Sender>, + worker_replier: Sender, MimeType)>>, ) -> Option> { std::thread::Builder::new() .name(name) @@ -26,16 +27,11 @@ pub fn spawn( } /// Clipboard worker thread command. -#[derive(Eq, PartialEq)] pub enum Command { - /// Store data to a clipboard. - Store(String), - /// Store data to a primary selection. - StorePrimary(String), - /// Load data from a clipboard. - Load, - /// Load primary selection. - LoadPrimary, + /// Loads data for the first available mime type in the provided list + Load(Vec, SelectionTarget), + Store(Box, SelectionTarget), + /// Store Data with the given Mime Types /// Shutdown the worker. Exit, } @@ -44,7 +40,7 @@ pub enum Command { fn worker_impl( connection: Connection, rx_chan: Channel, - reply_tx: Sender>, + reply_tx: Sender, MimeType)>>, ) { let (globals, event_queue) = match registry_queue_init(&connection) { Ok(data) => data, @@ -64,29 +60,23 @@ fn worker_impl( .insert_source(rx_chan, |event, _, state| { if let channel::Event::Msg(event) = event { match event { - Command::StorePrimary(contents) => { - state.store_selection(SelectionTarget::Primary, contents); - }, - Command::Store(contents) => { - state.store_selection(SelectionTarget::Clipboard, contents); - }, - Command::Load if state.data_device_manager_state.is_some() => { - if let Err(err) = state.load_selection(SelectionTarget::Clipboard) { - let _ = state.reply_tx.send(Err(err)); - } + Command::Exit => state.exit = true, + Command::Store(data, target) => { + state.store_selection(target, data); }, - Command::LoadPrimary if state.data_device_manager_state.is_some() => { - if let Err(err) = state.load_selection(SelectionTarget::Primary) { + Command::Load(mime_types, target) + if state.data_device_manager_state.is_some() => + { + if let Err(err) = state.load_selection(target, &mime_types) { let _ = state.reply_tx.send(Err(err)); } }, - Command::Load | Command::LoadPrimary => { + Command::Load(..) => { let _ = state.reply_tx.send(Err(Error::new( ErrorKind::Other, "requested selection is not supported", ))); }, - Command::Exit => state.exit = true, } } })