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},
},
primary_selection::{
PrimarySelectionManagerState,
device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},
selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},
},
};
use zeroize::{Zeroize, Zeroizing};
use smithay_client_toolkit::reexports::client::{
Connection, QueueHandle,
protocol::{
wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,
wl_data_source::WlDataSource, wl_seat, wl_surface,
},
};
use super::wayland::WaylandState;
pub use crate::reactive::SelectionKind;
use crate::reactive::clipboard::{answer_paste, set_clipboard_offered};
const TEXT_MIMES: [&str; 5] = [
"text/plain;charset=utf-8",
"UTF8_STRING",
"text/plain",
"TEXT",
"STRING",
];
fn text_mime(mimes: &[String]) -> Option<&'static str> {
TEXT_MIMES
.iter()
.find(|m| mimes.iter().any(|t| t == *m))
.copied()
}
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>,
}
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,
}
}
fn own_content(&self, kind: SelectionKind) -> Option<&String> {
match kind {
SelectionKind::Clipboard => self
.clipboard_source
.as_ref()
.and(self.clipboard_content.as_ref()),
SelectionKind::Primary => self
.primary_source
.as_ref()
.and(self.primary_content.as_ref()),
}
}
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, TEXT_MIMES);
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 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, TEXT_MIMES);
self.selections.primary_content = Some(text);
source.set_selection(device, self.input.latest_input_serial);
self.selections.primary_source = Some(source);
}
pub(crate) fn read_selection(&mut self, kind: SelectionKind, token: u64) {
if let Some(own) = self.selections.own_content(kind) {
answer_paste(token, Some(own.clone()));
return;
}
if self.start_read(kind, token).is_none() {
answer_paste(token, None);
}
}
fn start_read(&self, kind: SelectionKind, token: u64) -> Option<()> {
let pipe = match kind {
SelectionKind::Clipboard => {
let offer = self
.selections
.data_device
.as_ref()?
.data()
.selection_offer()?;
receive_text(kind, offer.with_mime_types(text_mime), |mime| {
offer.receive(mime)
})?
}
SelectionKind::Primary => {
let device = self.selections.primary_selection_device.as_ref()?;
let offer = device.data().selection_offer()?;
receive_text(kind, offer.with_mime_types(text_mime), |mime| {
offer.receive(mime)
})?
}
};
let sender = crate::ingress::sender_handle()?;
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::SelectionRead { token, content });
})
.map_err(|e| log::warn!("Failed to spawn clipboard reader thread: {e}"))
.ok()?;
Some(())
}
}
fn receive_text<E: std::fmt::Debug>(
kind: SelectionKind,
mime: Option<&'static str>,
receive: impl FnOnce(String) -> Result<ReadPipe, E>,
) -> Option<ReadPipe> {
let mime = mime?;
receive(mime.to_string())
.map_err(|e| log::debug!("Failed to receive {kind:?} as {mime}: {e:?}"))
.ok()
}
fn read_pipe_with_deadline(pipe: ReadPipe, deadline: Duration) -> Option<String> {
let mut file = File::from(OwnedFd::from(pipe));
let mut buf = Zeroizing::new(Vec::new());
let mut chunk = Zeroizing::new([0u8; 8192]);
read_with_deadline(&mut file, &mut buf, &mut chunk[..], deadline)?;
let text = match String::from_utf8(std::mem::take(&mut *buf)) {
Ok(text) => text,
Err(e) => {
let mut bytes = e.into_bytes();
let text = String::from_utf8_lossy(&bytes).into_owned();
bytes.zeroize();
text
}
};
(!text.is_empty()).then_some(text)
}
fn read_with_deadline(
file: &mut File,
buf: &mut Vec<u8>,
chunk: &mut [u8],
deadline: Duration,
) -> Option<()> {
use std::os::unix::io::AsRawFd;
let raw_fd = file.as_raw_fd();
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;
}
match file.read(chunk) {
Ok(0) => return Some(()), Ok(n) => {
drop(grow_wiping(buf, 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;
}
}
}
}
fn grow_wiping(buf: &mut Vec<u8>, n: usize) -> Option<Vec<u8>> {
if buf.capacity() - buf.len() >= n {
return None;
}
let mut grown = Vec::with_capacity((buf.len() + n).max(2 * buf.capacity()));
grown.extend_from_slice(buf);
let mut old = std::mem::replace(buf, grown);
old.zeroize();
Some(old)
}
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 offered = self
.selections
.data_device
.as_ref()
.and_then(|device| device.data().selection_offer())
.and_then(|offer| offer.with_mime_types(text_mime))
.is_some();
set_clipboard_offered(offered);
}
}
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");
}
}
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,
) {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_read_keeps_every_byte_across_its_grows() {
let text: String = (0..20_000)
.map(|i| char::from(b'a' + (i % 26) as u8))
.collect();
let (reader, mut writer) = std::io::pipe().unwrap();
let sent = text.clone();
let writing = std::thread::spawn(move || writer.write_all(sent.as_bytes()));
let mut file = File::from(OwnedFd::from(reader));
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
let read = read_with_deadline(&mut file, &mut buf, &mut chunk, Duration::from_secs(5));
writing.join().unwrap().unwrap();
assert_eq!(read, Some(()));
assert_eq!(buf, text.as_bytes());
}
fn pipe_holding(bytes: &[u8]) -> ReadPipe {
let (reader, mut writer) = std::io::pipe().unwrap();
writer.write_all(bytes).unwrap();
ReadPipe::from(OwnedFd::from(reader))
}
#[test]
fn a_pipe_is_read_as_text() {
let deadline = Duration::from_secs(5);
assert_eq!(
read_pipe_with_deadline(pipe_holding(b"copied"), deadline).as_deref(),
Some("copied")
);
assert_eq!(
read_pipe_with_deadline(pipe_holding(b""), deadline),
None,
"an empty selection is nothing to paste"
);
assert_eq!(
read_pipe_with_deadline(pipe_holding(b"ok\xff"), deadline).as_deref(),
Some("ok\u{fffd}"),
"bytes that are not UTF-8 are replaced, not refused"
);
}
#[test]
fn a_buffer_that_moves_is_wiped_where_it_was() {
let mut buf = Vec::with_capacity(4);
buf.extend_from_slice(b"abc");
assert!(grow_wiping(&mut buf, 1).is_none(), "one more byte fits");
let old = grow_wiping(&mut buf, 2).expect("two more do not");
assert_eq!(buf, b"abc", "the text moved with it");
assert!(buf.capacity() - buf.len() >= 2);
let left = unsafe { std::slice::from_raw_parts(old.as_ptr(), old.capacity()) };
assert!(
left.iter().all(|&b| b == 0),
"the old buffer was freed as it was"
);
}
#[test]
fn an_offer_is_received_as_the_text_type_chosen_for_it() {
let mut asked = None;
let pipe = receive_text(SelectionKind::Clipboard, Some("UTF8_STRING"), |mime| {
asked = Some(mime);
Ok::<_, ()>(pipe_holding(b""))
});
assert!(pipe.is_some());
assert_eq!(asked.as_deref(), Some("UTF8_STRING"));
let refused = receive_text(SelectionKind::Primary, Some("TEXT"), |_| Err(()));
assert!(refused.is_none());
let no_text = receive_text(SelectionKind::Clipboard, None, |_| -> Result<_, ()> {
panic!("an offer with no text type is not received")
});
assert!(no_text.is_none());
}
#[test]
fn the_preferred_text_type_is_the_one_read() {
let offered = ["STRING".to_owned(), "text/plain;charset=utf-8".to_owned()];
assert_eq!(text_mime(&offered), Some("text/plain;charset=utf-8"));
assert_eq!(text_mime(&["image/png".to_owned()]), None);
}
}