diff --git a/Cargo.lock b/Cargo.lock index ca76b16..9788284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -624,7 +624,6 @@ dependencies = [ "tracing-subscriber", "wayland-client", "wayland-protocols", - "wl-clipboard-rs", ] [[package]] @@ -1123,12 +1122,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.1.2" @@ -1804,12 +1797,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1913,16 +1900,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -2284,16 +2261,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2344,16 +2311,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap", -] - [[package]] name = "phf" version = "0.11.3" @@ -3127,18 +3084,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "tree_magic_mini" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" -dependencies = [ - "memchr", - "nom", - "once_cell", - "petgraph", -] - [[package]] name = "ttf-parser" version = "0.25.1" @@ -4109,25 +4054,6 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" -[[package]] -name = "wl-clipboard-rs" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 0.38.44", - "tempfile", - "thiserror 2.0.16", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 205341e..2b8e8c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,4 +15,3 @@ tracing = { version = "0.1.41", features = ["attributes"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } wayland-client = "0.31.11" wayland-protocols = { version = "0.32.9", features = ["staging"] } -wl-clipboard-rs = "0.9.2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..dae6b1c --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# clippyboard + +clippyboard is a Wayland clipboard manager daemon and UI. + +It provides a daemon that stores a clipboard history in memory and provides a socket to read and manage it. +A client program can then connect to it and read the contents and choose an item to copy to the clipboard again. + +A barebones egui-based client is provided for doing this. + +clippyboard provides first-class support for images! + +clippyboard currently supports the following MIME types: +- `text/plain` +- `image/png` +- `image/jpg` + +It will try to read out one of them (in descending preference) and store that value and provide it later. +If no supported MIME type is found, the clipboard entry is not stored. diff --git a/src/daemon.rs b/src/daemon.rs index 8671497..751df98 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7,10 +7,7 @@ use eyre::bail; use rustix::event::PollFd; use rustix::event::PollFlags; use rustix::fs::OFlags; -use rustix::fs::fcntl_setfl; -use rustix::io::FdFlags; use std::collections::HashMap; -use std::future::poll_fn; use std::io::PipeReader; use std::io::{BufReader, BufWriter, PipeWriter, Read, Write}; use std::os::fd::AsFd; @@ -24,11 +21,9 @@ use tracing::info; use tracing::warn; use tracing_subscriber::EnvFilter; use wayland_client::EventQueue; -use wayland_client::protocol::wl_callback::WlCallback; -use wayland_client::protocol::wl_display::WlDisplay; use wayland_client::protocol::wl_registry::WlRegistry; use wayland_client::protocol::wl_seat::WlSeat; -use wayland_client::{Dispatch, Proxy, QueueHandle, backend::ObjectId, event_created_child}; +use wayland_client::{Dispatch, Proxy, QueueHandle, event_created_child}; use wayland_protocols::ext::data_control::v1::client::ext_data_control_device_v1; use wayland_protocols::ext::data_control::v1::client::ext_data_control_device_v1::{ EVT_DATA_OFFER_OPCODE, ExtDataControlDeviceV1, @@ -39,28 +34,20 @@ use wayland_protocols::ext::data_control::v1::client::ext_data_control_offer_v1: use wayland_protocols::ext::data_control::v1::client::ext_data_control_source_v1; use wayland_protocols::ext::data_control::v1::client::ext_data_control_source_v1::ExtDataControlSourceV1; +const MIME_TYPES: &[&str] = &["text/plain", "image/png", "image/jpg"]; + struct SharedState { next_item_id: AtomicU64, - // for deduplication because the event stream will tell us that we just copied something :) - last_copied_item_id: AtomicU64, items: Mutex>, notify_write_send: PipeWriter, data_control_manager: OnceLock, data_control_devices: Mutex>, qh: QueueHandle, - d: WlDisplay, } struct InProgressOffer { - mime_types: HashSet, - time: Duration, -} - -#[derive(Debug)] -struct CurrentSelection { - mime_types: HashSet, - offer: ExtDataControlOfferV1, + mime_types: Mutex>, time: Duration, } @@ -69,23 +56,6 @@ struct WlState { /// wl_seat that arrived before the data control manager so we weren't able to grab their device immediatly. deferred_seats: Vec, - - offers: HashMap, - current_primary_selection: Option, - current_selection: Option, -} - -impl Dispatch for WlState { - fn event( - _state: &mut Self, - _proxy: &WlCallback, - _event: ::Event, - data: &String, - _conn: &wayland_client::Connection, - _qhandle: &QueueHandle, - ) { - info!("Received sync back {data}"); - } } impl Dispatch for WlState { @@ -163,6 +133,7 @@ impl Dispatch for WlState { _conn: &wayland_client::Connection, _qhandle: &wayland_client::QueueHandle, ) { + // no events at the time of writing } } impl Dispatch for WlState { @@ -174,6 +145,7 @@ impl Dispatch for WlState { _conn: &wayland_client::Connection, _qhandle: &wayland_client::QueueHandle, ) { + // we don't care about anything about the seat } } impl Dispatch for WlState { @@ -186,88 +158,49 @@ impl Dispatch for WlState { _qhandle: &wayland_client::QueueHandle, ) { match event { - // A new offer is being prepared, register it and don't do anything yet - ext_data_control_device_v1::Event::DataOffer { id } => { - state.offers.insert( - id.id(), - InProgressOffer { - mime_types: Default::default(), - time: SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap(), - }, - ); + ext_data_control_device_v1::Event::DataOffer { id: _ } => { + // A new offer is being prepared, we created the associated data in its creation and don't need to do anything } // The selection has been confirmed, we just properly got a new offer that we should use. ext_data_control_device_v1::Event::Selection { id } => { - let new_offer = match id { - Some(id) => { - let offer = state.offers.remove(&id.id()); + if let Some(offer) = id { + let offer_data = offer + .data::() + .expect("missing InProgressOffer data for ExtDataControlOfferV1"); - offer.map(|offer| CurrentSelection { - offer: id, - mime_types: offer.mime_types, - time: offer.time, - }) - } - None => None, - }; - - if let Some(current) = &state.current_selection { - current.offer.destroy(); - } - - state.current_selection = new_offer; - - if let Some(offer) = state.current_selection.take() { - let Some(mime) = ["text/plain", "image/png"] - .iter() - .find(|mime| offer.mime_types.contains(**mime)) + let mime_types = offer_data.mime_types.lock().unwrap(); + let Some(mime) = MIME_TYPES.iter().find(|mime| mime_types.contains(**mime)) else { warn!( "No supported mime type found. Found mime types: {:?}", - offer.mime_types + offer_data.mime_types ); return; }; + drop(mime_types); let (reader, writer) = std::io::pipe().unwrap(); - offer.offer.receive(mime.to_string(), writer.as_fd()); + offer.receive(mime.to_string(), writer.as_fd()); let history_state = state.shared_state.clone(); let mime = mime.to_string(); - let time = offer.time; + let time = offer_data.time; std::thread::spawn(move || { - let result = - do_read_clipboard_into_history(&history_state, time, mime, reader); + let result = read_fd_into_history(&history_state, time, mime, reader); if let Err(err) = result { warn!("Failed to read clipboard: {:?}", err) } - offer.offer.destroy(); + offer.destroy(); }); } } + // The offer has been confirmed to be a primary selection, do the necessary bookkeeping but we don't really care. ext_data_control_device_v1::Event::PrimarySelection { id } => { - let new_offer = match id { - Some(id) => { - let offer = state.offers.remove(&id.id()); - - offer.map(|offer| CurrentSelection { - offer: id, - mime_types: offer.mime_types, - time: offer.time, - }) - } - None => None, - }; - - if let Some(current) = &state.current_primary_selection { - current.offer.destroy(); + if let Some(id) = id { + id.destroy(); } - - state.current_primary_selection = new_offer; } ext_data_control_device_v1::Event::Finished => { warn!("device finished :("); @@ -277,24 +210,27 @@ impl Dispatch for WlState { } event_created_child!(WlState, ExtDataControlDeviceV1, [ - EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, ()), + EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, InProgressOffer { + mime_types: Default::default(), + time: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(), + }), ]); } -impl Dispatch for WlState { +impl Dispatch for WlState { fn event( - state: &mut Self, - proxy: &ExtDataControlOfferV1, + _state: &mut Self, + _proxy: &ExtDataControlOfferV1, event: ::Event, - _data: &(), + data: &InProgressOffer, _conn: &wayland_client::Connection, _qhandle: &wayland_client::QueueHandle, ) { match event { ext_data_control_offer_v1::Event::Offer { mime_type } => { - if let Some(offer) = state.offers.get_mut(&proxy.id()) { - offer.mime_types.insert(mime_type); - } + data.mime_types.lock().unwrap().insert(mime_type); } _ => {} } @@ -312,8 +248,6 @@ impl Dispatch for WlState { ) { match event { ext_data_control_source_v1::Event::Send { mime_type: _, fd } => { - info!("pasting {:?}", std::str::from_utf8(&data.0)); - let data = data.0.clone(); std::thread::spawn(move || { @@ -330,7 +264,6 @@ impl Dispatch for WlState { }); } ext_data_control_source_v1::Event::Cancelled => { - info!("We have been replaced."); proxy.destroy(); } _ => {} @@ -338,6 +271,164 @@ impl Dispatch for WlState { } } +fn do_copy_into_clipboard( + entry: HistoryItem, + shared_state: &SharedState, +) -> Result<(), eyre::Error> { + for device in &*shared_state.data_control_devices.lock().unwrap() { + let data_source = shared_state + .data_control_manager + .get() + .expect("data manger not found") + .create_data_source(&shared_state.qh, OfferData(entry.data.clone())); + + if entry.mime == "text/plain" { + // Just like wl_clipboard_rs, we also offer some extra mimes for text. + let text_mimes = [ + "text/plain;charset=utf-8", + "text/plain", + "STRING", + "UTF8_STRING", + "TEXT", + ]; + for mime in text_mimes { + data_source.offer(mime.to_string()); + } + } else { + data_source.offer(entry.mime.clone()); + } + + device.1.set_selection(Some(&data_source)); + } + + Ok(()) +} + +fn dispatch_wayland( + mut queue: EventQueue, + mut wl_state: WlState, + notify_write_recv: PipeReader, +) -> eyre::Result<()> { + loop { + queue + .dispatch_pending(&mut wl_state) + .wrap_err("dispatching Wayland events")?; + + let read_guard = queue + .prepare_read() + .wrap_err("preparing read from Wayland socket")?; + let _ = queue.flush(); + + let pollfd1_read = PollFd::from_borrowed_fd(read_guard.connection_fd(), PollFlags::IN); + let pollfd_signal = PollFd::from_borrowed_fd(notify_write_recv.as_fd(), PollFlags::IN); + + let _ = rustix::event::poll(&mut [pollfd1_read, pollfd_signal], None); + + read_guard + .read_without_dispatch() + .wrap_err("reading from wayland socket")?; + } +} + +#[tracing::instrument(skip(peer, shared_state))] +fn handle_peer(mut peer: UnixStream, shared_state: &SharedState) -> eyre::Result<()> { + let mut request = [0; 1]; + let Ok(()) = peer.read_exact(&mut request) else { + return Ok(()); + }; + match request[0] { + super::MESSAGE_READ => { + let items = shared_state.items.lock().unwrap(); + + ciborium::into_writer(items.as_slice(), BufWriter::new(peer)) + .wrap_err("writing items to socket")?; + } + super::MESSAGE_COPY => { + handle_copy_message(peer, shared_state).wrap_err("handling copy message")?; + } + _ => {} + }; + Ok(()) +} + +struct OfferData(Arc<[u8]>); + +fn handle_copy_message( + mut peer: UnixStream, + shared_state: &SharedState, +) -> Result<(), eyre::Error> { + let mut id = [0; 8]; + peer.read_exact(&mut id).wrap_err("failed to read id")?; + let id = u64::from_le_bytes(id); + let mut items = shared_state.items.lock().unwrap(); + let Some(idx) = items.iter().position(|item| item.id == id) else { + return Ok(()); + }; + let item = items.remove(idx); + items.push(item.clone()); + + drop(items); + + do_copy_into_clipboard(item, &shared_state).wrap_err("doing copy")?; + + (&shared_state.notify_write_send) + .write_all(&[0]) + .wrap_err("notifying wayland thread")?; + + Ok(()) +} + +fn read_fd_into_history( + history_state: &SharedState, + time: std::time::Duration, + mime: String, + data_reader: impl Read, +) -> Result<(), eyre::Error> { + let mut data_reader = BufReader::new(data_reader).take(MAX_ENTRY_SIZE); + let mut data = Vec::new(); + data_reader + .read_to_end(&mut data) + .wrap_err("reading content data")?; + let new_entry = HistoryItem { + id: history_state + .next_item_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + mime: mime.to_string(), + data: data.into(), + created_time: u64::try_from(time.as_millis()).unwrap(), + }; + let mut items = history_state.items.lock().unwrap(); + if items + .last() + .is_some_and(|last| last.mime == new_entry.mime && last.data == new_entry.data) + { + info!("INFO: Skipping store of new item because it is identical to last one"); + return Ok(()); + } + + items.push(new_entry); + let mut running_total = 0; + let mut cutoff = None; + for (idx, item) in items.iter().enumerate().rev() { + running_total += item.data.len() + std::mem::size_of::(); + if running_total > crate::MAX_HISTORY_BYTE_SIZE { + cutoff = Some(idx); + } + } + if let Some(cutoff) = cutoff { + info!( + "Dropping old {} items because limit of {} bytes was reached for the history", + cutoff + 1, + crate::MAX_HISTORY_BYTE_SIZE + ); + items.splice(0..=cutoff, []); + } + info!( + "Successfully stored clipboard value of mime type {mime} (new history size {running_total})" + ); + Ok(()) +} + pub fn main(socket_path: &PathBuf) -> eyre::Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new("info"))) @@ -356,29 +447,17 @@ pub fn main(socket_path: &PathBuf) -> eyre::Result<()> { let shared_state = Arc::new(SharedState { next_item_id: AtomicU64::new(0), - last_copied_item_id: AtomicU64::new(u64::MAX), items: Mutex::new(Vec::::new()), notify_write_send, data_control_manager: OnceLock::new(), data_control_devices: Mutex::new(HashMap::new()), qh: queue.handle(), - d: conn.display(), - }); - - shared_state.items.lock().unwrap().push(HistoryItem { - id: 3548235782, - mime: "text/plain".into(), - data: b"meow".to_vec().into(), - created_time: 0, }); let history_state2 = shared_state.clone(); let mut wl_state = WlState { - offers: HashMap::new(), - current_primary_selection: None, - current_selection: None, deferred_seats: Vec::new(), shared_state: history_state2, @@ -430,188 +509,3 @@ pub fn main(socket_path: &PathBuf) -> eyre::Result<()> { Ok(()) } - -fn dispatch_wayland( - mut queue: EventQueue, - mut wl_state: WlState, - notify_write_recv: PipeReader, -) -> eyre::Result<()> { - loop { - queue - .dispatch_pending(&mut wl_state) - .wrap_err("dispatching Wayland events")?; - - let read_guard = queue - .prepare_read() - .wrap_err("preparing read from Wayland socket")?; - let _ = queue.flush(); - - let pollfd1_read = PollFd::from_borrowed_fd(read_guard.connection_fd(), PollFlags::IN); - let pollfd_signal = PollFd::from_borrowed_fd(notify_write_recv.as_fd(), PollFlags::IN); - - let _ = rustix::event::poll(&mut [pollfd1_read, pollfd_signal], None); - - read_guard - .read_without_dispatch() - .wrap_err("reading from wayland socket")?; - } -} - -#[tracing::instrument(skip(peer, shared_state), level = "info")] -fn handle_peer(mut peer: UnixStream, shared_state: &SharedState) -> eyre::Result<()> { - let mut request = [0; 1]; - let Ok(()) = peer.read_exact(&mut request) else { - return Ok(()); - }; - match request[0] { - super::MESSAGE_READ => { - let items = shared_state.items.lock().unwrap(); - - ciborium::into_writer(items.as_slice(), BufWriter::new(peer)) - .wrap_err("writing items to socket")?; - } - super::MESSAGE_COPY => { - handle_copy(peer, shared_state).wrap_err("handling copy message")?; - } - _ => {} - }; - Ok(()) -} - -struct OfferData(Arc<[u8]>); - -fn handle_copy(mut peer: UnixStream, shared_state: &SharedState) -> Result<(), eyre::Error> { - let mut id = [0; 8]; - peer.read_exact(&mut id).wrap_err("failed to read id")?; - let id = u64::from_le_bytes(id); - let mut items = shared_state.items.lock().unwrap(); - let Some(idx) = items.iter().position(|item| item.id == id) else { - return Ok(()); - }; - let item = items.remove(idx); - items.push(item.clone()); - - drop(items); - - do_copy_into_clipboard(item, &shared_state).wrap_err("doing copy")?; - - (&shared_state.notify_write_send) - .write_all(&[0]) - .wrap_err("notifying wayland thread")?; - - Ok(()) -} - -fn do_copy_into_clipboard( - entry: HistoryItem, - shared_state: &SharedState, -) -> Result<(), eyre::Error> { - for device in &*shared_state.data_control_devices.lock().unwrap() { - shared_state - .d - .sync(&shared_state.qh, "before create_data_source".into()); - let data_source = shared_state - .data_control_manager - .get() - .expect("data manger not found") - .create_data_source(&shared_state.qh, OfferData(entry.data.clone())); - - shared_state - .d - .sync(&shared_state.qh, "after create_data_source".into()); - - if entry.mime == "text/plain" { - // Just like wl_clipboard_rs, we also offer some extra mimes for text. - let text_mimes = [ - "text/plain;charset=utf-8", - "text/plain", - "STRING", - "UTF8_STRING", - "TEXT", - ]; - for mime in text_mimes { - data_source.offer(mime.to_string()); - } - } else { - data_source.offer(entry.mime.clone()); - } - - shared_state - .d - .sync(&shared_state.qh, "before set_selection".into()); - - info!("setting the selection"); - - device.1.set_selection(Some(&data_source)); - - shared_state - .d - .sync(&shared_state.qh, "setting the selection".into()); - } - - shared_state - .last_copied_item_id - .store(entry.id, std::sync::atomic::Ordering::Relaxed); - - Ok(()) -} - -fn do_read_clipboard_into_history( - history_state: &SharedState, - time: std::time::Duration, - mime: String, - data_reader: impl Read, -) -> Result<(), eyre::Error> { - let mut data_reader = BufReader::new(data_reader).take(MAX_ENTRY_SIZE); - let mut data = Vec::new(); - data_reader - .read_to_end(&mut data) - .wrap_err("reading content data")?; - let new_entry = HistoryItem { - id: history_state - .next_item_id - .fetch_add(1, std::sync::atomic::Ordering::Relaxed), - mime: mime.to_string(), - data: data.into(), - created_time: u64::try_from(time.as_millis()).unwrap(), - }; - let mut items = history_state.items.lock().unwrap(); - if items - .last() - .is_some_and(|last| last.mime == new_entry.mime && last.data == new_entry.data) - { - info!("INFO: Skipping store of new item because it is identical to last one"); - return Ok(()); - } - let last_copied = history_state - .last_copied_item_id - .load(std::sync::atomic::Ordering::Relaxed); - if let Some(item) = items.iter().find(|item| item.id == last_copied) - && item.mime == new_entry.mime - && item.data == new_entry.data - { - info!("Skipping store of new item because the copy came from us"); - return Ok(()); - } - items.push(new_entry); - let mut running_total = 0; - let mut cutoff = None; - for (idx, item) in items.iter().enumerate().rev() { - running_total += item.data.len() + std::mem::size_of::(); - if running_total > crate::MAX_HISTORY_BYTE_SIZE { - cutoff = Some(idx); - } - } - if let Some(cutoff) = cutoff { - info!( - "Dropping old {} items because limit of {} bytes was reached for the history", - cutoff + 1, - crate::MAX_HISTORY_BYTE_SIZE - ); - items.splice(0..=cutoff, []); - } - info!( - "Successfully stored clipboard value of mime type {mime} (new history size {running_total})" - ); - Ok(()) -}