This commit is contained in:
nora 2025-09-18 20:36:29 +02:00
parent fce09d16cd
commit cead214aa1
4 changed files with 210 additions and 373 deletions

74
Cargo.lock generated
View file

@ -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"

View file

@ -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"

18
README.md Normal file
View file

@ -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.

View file

@ -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<Vec<HistoryItem>>,
notify_write_send: PipeWriter,
data_control_manager: OnceLock<ExtDataControlManagerV1>,
data_control_devices: Mutex<HashMap</*seat global name */ u32, ExtDataControlDeviceV1>>,
qh: QueueHandle<WlState>,
d: WlDisplay,
}
struct InProgressOffer {
mime_types: HashSet<String>,
time: Duration,
}
#[derive(Debug)]
struct CurrentSelection {
mime_types: HashSet<String>,
offer: ExtDataControlOfferV1,
mime_types: Mutex<HashSet<String>>,
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<WlSeat>,
offers: HashMap<ObjectId, InProgressOffer>,
current_primary_selection: Option<CurrentSelection>,
current_selection: Option<CurrentSelection>,
}
impl Dispatch<WlCallback, String> for WlState {
fn event(
_state: &mut Self,
_proxy: &WlCallback,
_event: <WlCallback as Proxy>::Event,
data: &String,
_conn: &wayland_client::Connection,
_qhandle: &QueueHandle<Self>,
) {
info!("Received sync back {data}");
}
}
impl Dispatch<WlRegistry, ()> for WlState {
@ -163,6 +133,7 @@ impl Dispatch<ExtDataControlManagerV1, ()> for WlState {
_conn: &wayland_client::Connection,
_qhandle: &wayland_client::QueueHandle<Self>,
) {
// no events at the time of writing
}
}
impl Dispatch<WlSeat, ()> for WlState {
@ -174,6 +145,7 @@ impl Dispatch<WlSeat, ()> for WlState {
_conn: &wayland_client::Connection,
_qhandle: &wayland_client::QueueHandle<Self>,
) {
// we don't care about anything about the seat
}
}
impl Dispatch<ExtDataControlDeviceV1, ()> for WlState {
@ -186,88 +158,49 @@ impl Dispatch<ExtDataControlDeviceV1, ()> for WlState {
_qhandle: &wayland_client::QueueHandle<Self>,
) {
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::<InProgressOffer>()
.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<ExtDataControlDeviceV1, ()> 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<ExtDataControlOfferV1, ()> for WlState {
impl Dispatch<ExtDataControlOfferV1, InProgressOffer> for WlState {
fn event(
state: &mut Self,
proxy: &ExtDataControlOfferV1,
_state: &mut Self,
_proxy: &ExtDataControlOfferV1,
event: <ExtDataControlOfferV1 as wayland_client::Proxy>::Event,
_data: &(),
data: &InProgressOffer,
_conn: &wayland_client::Connection,
_qhandle: &wayland_client::QueueHandle<Self>,
) {
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<ExtDataControlSourceV1, OfferData> 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<ExtDataControlSourceV1, OfferData> for WlState {
});
}
ext_data_control_source_v1::Event::Cancelled => {
info!("We have been replaced.");
proxy.destroy();
}
_ => {}
@ -338,6 +271,164 @@ impl Dispatch<ExtDataControlSourceV1, OfferData> 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<WlState>,
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::<HistoryItem>();
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::<HistoryItem>::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<WlState>,
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::<HistoryItem>();
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(())
}