use std::fs::File;
use std::io::{Read, Write};
use std::os::fd::OwnedFd;
use std::time::{Duration, Instant};
use smithay_client_toolkit::{
data_device_manager::{
DataDeviceManagerState, ReadPipe,
data_device::{DataDevice, DataDeviceHandler},
data_offer::DataOfferHandler,
data_source::{CopyPasteSource, DataSourceHandler},
},
delegate_data_device, delegate_primary_selection,
primary_selection::{
PrimarySelectionManagerState,
device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},
selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},
},
};
use smithay_client_toolkit::reexports::client::{
Connection, Proxy, QueueHandle,
protocol::{
wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,
wl_data_source::WlDataSource, wl_seat, wl_surface,
},
};
use super::wayland::WaylandState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionKind {
Clipboard,
Primary,
}
pub struct Selections {
pub(super) data_device_manager: Option<DataDeviceManagerState>,
pub(super) data_device: Option<DataDevice>,
pub(super) clipboard_content: Option<String>,
pub(super) clipboard_source: Option<CopyPasteSource>,
pub(super) primary_selection_manager: Option<PrimarySelectionManagerState>,
pub(super) primary_selection_device: Option<PrimarySelectionDevice>,
pub(super) primary_content: Option<String>,
pub(super) primary_source: Option<PrimarySelectionSource>,
selection_generation: u64,
primary_generation: u64,
}
impl Selections {
pub(super) fn new(
data_device_manager: Option<DataDeviceManagerState>,
primary_selection_manager: Option<PrimarySelectionManagerState>,
) -> Self {
Self {
data_device_manager,
data_device: None,
clipboard_content: None,
clipboard_source: None,
primary_selection_manager,
primary_selection_device: None,
primary_content: None,
primary_source: None,
selection_generation: 0,
primary_generation: 0,
}
}
pub(super) fn apply(&self, kind: SelectionKind, generation: u64, content: Option<String>) {
let current = match kind {
SelectionKind::Clipboard => self.selection_generation,
SelectionKind::Primary => self.primary_generation,
};
if generation != current {
log::debug!("Dropping stale {kind:?} content (gen {generation} != {current})");
return;
}
match kind {
SelectionKind::Clipboard => match content {
Some(text) => crate::reactive::set_system_clipboard(text),
None => crate::reactive::clear_system_clipboard(),
},
SelectionKind::Primary => crate::reactive::set_system_primary(content),
}
}
fn next_generation(&mut self, kind: SelectionKind) -> u64 {
let generation = match kind {
SelectionKind::Clipboard => &mut self.selection_generation,
SelectionKind::Primary => &mut self.primary_generation,
};
*generation += 1;
*generation
}
pub(super) fn clear(&mut self, kind: SelectionKind) {
let generation = self.next_generation(kind);
self.apply(kind, generation, None);
}
pub(super) fn offer(
&mut self,
kind: SelectionKind,
start_reader: impl FnOnce(u64) -> Result<(), String>,
) {
let generation = self.next_generation(kind);
if let Err(e) = start_reader(generation) {
log::warn!("Selection prefetch skipped: {e}");
self.apply(kind, generation, None);
}
}
pub(super) fn attach_devices(
&mut self,
qh: &QueueHandle<WaylandState>,
seat: &wl_seat::WlSeat,
) {
if self.data_device.is_none()
&& let Some(ref manager) = self.data_device_manager
{
log::info!("Creating data device for clipboard");
self.data_device = Some(manager.get_data_device(qh, seat));
}
if self.primary_selection_device.is_none()
&& let Some(ref manager) = self.primary_selection_manager
{
log::info!("Creating primary selection device");
self.primary_selection_device = Some(manager.get_selection_device(qh, seat));
}
}
}
impl WaylandState {
pub fn set_clipboard(&mut self, text: String) {
let qh = &self.qh;
if let Some(ref manager) = self.selections.data_device_manager {
let source = manager.create_copy_paste_source(
qh,
vec!["text/plain;charset=utf-8", "UTF8_STRING", "TEXT", "STRING"],
);
self.selections.clipboard_content = Some(text);
if let Some(ref device) = self.selections.data_device {
source.set_selection(device, self.input.keyboard_serial);
self.selections.clipboard_source = Some(source);
}
}
}
pub fn get_clipboard(&self) -> Option<String> {
self.selections.clipboard_content.clone()
}
pub fn set_primary(&mut self, text: String) {
let qh = &self.qh;
let Some(ref manager) = self.selections.primary_selection_manager else {
return;
};
let Some(ref device) = self.selections.primary_selection_device else {
return;
};
let source = manager.create_selection_source(
qh,
vec!["text/plain;charset=utf-8", "UTF8_STRING", "TEXT", "STRING"],
);
self.selections.primary_content = Some(text);
source.set_selection(device, self.input.latest_input_serial);
self.selections.primary_source = Some(source);
}
pub(crate) fn apply_clipboard_update(
&mut self,
kind: SelectionKind,
generation: u64,
content: Option<String>,
) {
self.selections.apply(kind, generation, content);
}
fn prefetch_selection<R>(&mut self, kind: SelectionKind, mimes: Vec<String>, receive: R)
where
R: FnOnce(&str) -> Option<ReadPipe>,
{
const PREFERRED: [&str; 5] = [
"text/plain;charset=utf-8",
"UTF8_STRING",
"text/plain",
"TEXT",
"STRING",
];
let mime = PREFERRED
.iter()
.find(|m| mimes.iter().any(|t| t == *m))
.copied();
let Some(pipe) = mime.and_then(receive) else {
self.selections.clear(kind);
return;
};
self.selections.offer(kind, |generation| {
let sender = crate::ingress::sender_handle()
.ok_or_else(|| "no event loop running".to_string())?;
std::thread::Builder::new()
.name("guido-clipboard-read".into())
.spawn(move || {
let content = read_pipe_with_deadline(pipe, Duration::from_secs(3));
sender.send(crate::ingress::IngressMessage::ClipboardUpdate {
kind,
generation,
content,
});
})
.map(|_| ())
.map_err(|e| format!("failed to spawn clipboard reader thread: {e}"))
});
}
}
fn read_pipe_with_deadline(pipe: ReadPipe, deadline: Duration) -> Option<String> {
use std::os::unix::io::AsRawFd;
let fd = OwnedFd::from(pipe);
let mut file = File::from(fd);
let raw_fd = file.as_raw_fd();
let mut buf = Vec::new();
let end = Instant::now() + deadline;
loop {
let remaining = end.saturating_duration_since(Instant::now());
if remaining.is_zero() {
log::warn!("Clipboard read timed out after {:?}", deadline);
return None;
}
let mut poll_fd = libc::pollfd {
fd: raw_fd,
events: libc::POLLIN,
revents: 0,
};
let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as i32;
let ret = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
if ret < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
log::warn!("Clipboard read poll failed: {err}");
return None;
}
if ret == 0 {
log::warn!("Clipboard read timed out after {:?}", deadline);
return None;
}
let mut chunk = [0u8; 8192];
match file.read(&mut chunk) {
Ok(0) => break, Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => {
log::warn!("Clipboard read failed: {e}");
return None;
}
}
}
let text = String::from_utf8_lossy(&buf).into_owned();
(!text.is_empty()).then_some(text)
}
impl DataDeviceHandler for WaylandState {
fn enter(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_data_device: &WlDataDevice,
_x: f64,
_y: f64,
_surface: &wl_surface::WlSurface,
) {
}
fn leave(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _data_device: &WlDataDevice) {
}
fn motion(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_data_device: &WlDataDevice,
_x: f64,
_y: f64,
) {
}
fn drop_performed(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_data_device: &WlDataDevice,
) {
}
fn selection(
&mut self,
conn: &Connection,
_qh: &QueueHandle<Self>,
_data_device: &WlDataDevice,
) {
log::debug!("Clipboard selection changed");
let offer = self
.selections
.data_device
.as_ref()
.and_then(|device| device.data().selection_offer());
match offer {
None => {
self.selections.clear(SelectionKind::Clipboard);
}
Some(offer) => {
let mimes = offer.with_mime_types(|t| t.to_vec());
self.prefetch_selection(SelectionKind::Clipboard, mimes, |mime| {
offer
.receive(mime.to_string())
.map_err(|e| log::debug!("Failed to receive clipboard as {mime}: {e:?}"))
.ok()
});
let _ = conn.flush();
}
}
}
}
impl PrimarySelectionDeviceHandler for WaylandState {
fn selection(
&mut self,
conn: &Connection,
_qh: &QueueHandle<Self>,
device: &smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,
) {
log::debug!("Primary selection changed");
let offer = device
.data::<smithay_client_toolkit::primary_selection::device::PrimarySelectionDeviceData>()
.and_then(|data| data.selection_offer());
match offer {
None => {
self.selections.clear(SelectionKind::Primary);
}
Some(offer) => {
let mimes = offer.with_mime_types(|t| t.to_vec());
self.prefetch_selection(SelectionKind::Primary, mimes, |mime| {
offer
.receive(mime.to_string())
.map_err(|e| log::debug!("Failed to receive primary as {mime}: {e:?}"))
.ok()
});
let _ = conn.flush();
}
}
}
}
impl PrimarySelectionSourceHandler for WaylandState {
fn send_request(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_source: &smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
mime: String,
write_pipe: smithay_client_toolkit::data_device_manager::WritePipe,
) {
log::debug!("Primary selection send request for mime type: {mime}");
if let Some(ref content) = self.selections.primary_content {
let content = content.clone();
let owned_fd = OwnedFd::from(write_pipe);
if let Err(e) = std::thread::Builder::new()
.name("guido-primary-send".into())
.spawn(move || {
let mut file = File::from(owned_fd);
if let Err(e) = file.write_all(content.as_bytes()) {
log::warn!("Failed to write primary selection content: {e}");
}
})
{
log::warn!("Failed to spawn primary selection writer thread: {e}");
}
}
}
fn cancelled(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_source: &smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
) {
log::debug!("Primary selection source cancelled");
self.selections.primary_source = None;
}
}
impl DataOfferHandler for WaylandState {
fn source_actions(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_offer: &mut smithay_client_toolkit::data_device_manager::data_offer::DragOffer,
_actions: DndAction,
) {
}
fn selected_action(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_offer: &mut smithay_client_toolkit::data_device_manager::data_offer::DragOffer,
_action: DndAction,
) {
}
}
impl DataSourceHandler for WaylandState {
fn accept_mime(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_source: &WlDataSource,
_mime: Option<String>,
) {
}
fn send_request(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_source: &WlDataSource,
mime: String,
fd: smithay_client_toolkit::data_device_manager::WritePipe,
) {
log::debug!("Clipboard send request for mime type: {}", mime);
if let Some(ref content) = self.selections.clipboard_content {
let content = content.clone();
let owned_fd = OwnedFd::from(fd);
if let Err(e) = std::thread::Builder::new()
.name("guido-clipboard-send".into())
.spawn(move || {
let mut file = File::from(owned_fd);
if let Err(e) = file.write_all(content.as_bytes()) {
log::warn!("Failed to write clipboard content: {}", e);
}
})
{
log::warn!("Failed to spawn clipboard writer thread: {e}");
}
}
}
fn cancelled(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource) {
log::debug!("Clipboard source cancelled");
self.selections.clipboard_source = None;
}
fn dnd_dropped(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource) {
}
fn dnd_finished(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_source: &WlDataSource,
) {
}
fn action(
&mut self,
_conn: &Connection,
_qh: &QueueHandle<Self>,
_source: &WlDataSource,
_action: DndAction,
) {
}
}
delegate_data_device!(WaylandState);
delegate_primary_selection!(WaylandState);
#[cfg(test)]
mod tests {
use super::*;
use crate::reactive::{
clipboard_paste, primary_paste, set_system_clipboard, set_system_primary,
};
#[test]
fn an_offer_whose_read_cannot_start_leaves_nothing_to_paste() {
let mut selections = Selections::new(None, None);
set_system_clipboard("copied two selections ago".into());
selections.offer(SelectionKind::Clipboard, |_| {
Err("failed to spawn clipboard reader thread".into())
});
assert_eq!(clipboard_paste(), None);
set_system_primary(Some("selected two selections ago".into()));
selections.offer(SelectionKind::Primary, |_| {
Err("no event loop running".into())
});
assert_eq!(
primary_paste(),
None,
"and the middle click has the same hole"
);
}
#[test]
fn an_offer_being_read_keeps_the_text_until_the_read_lands() {
let mut selections = Selections::new(None, None);
set_system_clipboard("the last owner's".into());
let mut started = None;
selections.offer(SelectionKind::Clipboard, |generation| {
started = Some(generation);
Ok(())
});
assert_eq!(clipboard_paste().as_deref(), Some("the last owner's"));
selections.apply(
SelectionKind::Clipboard,
started.unwrap(),
Some("this one's".into()),
);
assert_eq!(clipboard_paste().as_deref(), Some("this one's"));
}
#[test]
fn a_read_overtaken_by_a_newer_offer_is_dropped() {
let mut selections = Selections::new(None, None);
let mut first = None;
selections.offer(SelectionKind::Clipboard, |generation| {
first = Some(generation);
Ok(())
});
let mut second = None;
selections.offer(SelectionKind::Clipboard, |generation| {
second = Some(generation);
Ok(())
});
selections.apply(
SelectionKind::Clipboard,
second.unwrap(),
Some("newer".into()),
);
selections.apply(
SelectionKind::Clipboard,
first.unwrap(),
Some("older".into()),
);
assert_eq!(clipboard_paste().as_deref(), Some("newer"));
}
}