use std::collections::HashMap;
use std::ffi::c_int;
use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc};
use std::time::{Duration, Instant};
use crate::{ClipboardError, MimeType, Selection};
use super::wayland::WaylandConnection;
use super::wayland_socket::WaylandSocket;
use super::wayland_wire::{encode_message, encode_string, encode_u32, parse_string, parse_u32};
const WL_DISPLAY_ID: u32 = 1;
const FIRST_CLIENT_ID: u32 = 4;
const EXT_DATA_CONTROL_MANAGER: &str = "ext_data_control_manager_v1";
const WL_SEAT: &str = "wl_seat";
const ZWP_PRIMARY_SEL_MANAGER: &str = "zwp_primary_selection_device_manager_v1";
const WL_DISPLAY_SYNC: u16 = 0;
#[allow(dead_code)]
const WL_DISPLAY_GET_REGISTRY: u16 = 1;
const WL_REGISTRY_BIND: u16 = 0;
const EXT_MANAGER_CREATE_DATA_SOURCE: u16 = 0;
const EXT_MANAGER_GET_DATA_DEVICE: u16 = 1;
const EXT_DEVICE_SET_SELECTION: u16 = 0;
#[allow(dead_code)]
const EXT_DEVICE_SET_PRIMARY_SELECTION: u16 = 2;
const EXT_SOURCE_OFFER: u16 = 0;
const EXT_SOURCE_DESTROY: u16 = 1;
const EXT_OFFER_RECEIVE: u16 = 0;
const EXT_OFFER_DESTROY: u16 = 1;
const ZWP_PRIMARY_MANAGER_CREATE_SOURCE: u16 = 0;
const ZWP_PRIMARY_MANAGER_GET_DEVICE: u16 = 1;
const ZWP_PRIMARY_DEVICE_SET_SELECTION: u16 = 0;
const ZWP_PRIMARY_SOURCE_OFFER: u16 = 0;
const ZWP_PRIMARY_SOURCE_DESTROY: u16 = 1;
const ZWP_PRIMARY_OFFER_RECEIVE: u16 = 0;
const ZWP_PRIMARY_OFFER_DESTROY: u16 = 1;
const WL_DISPLAY_ERROR: u16 = 0;
const WL_DISPLAY_DELETE_ID: u16 = 1;
const WL_CALLBACK_DONE: u16 = 0;
const EXT_SOURCE_SEND: u16 = 0;
const EXT_SOURCE_CANCELLED: u16 = 1;
const EXT_OFFER_OFFER: u16 = 0;
const EXT_DEVICE_DATA_OFFER: u16 = 0; const EXT_DEVICE_SELECTION: u16 = 1; const EXT_DEVICE_FINISHED: u16 = 2;
const EXT_DEVICE_PRIMARY_SELECTION: u16 = 3;
const ZWP_PRIMARY_SOURCE_SEND: u16 = 0;
const ZWP_PRIMARY_SOURCE_CANCELLED: u16 = 1;
#[allow(dead_code)]
const ZWP_PRIMARY_OFFER_OFFER: u16 = 0;
const ZWP_PRIMARY_DEVICE_DATA_OFFER: u16 = 0;
const ZWP_PRIMARY_DEVICE_SELECTION: u16 = 1;
const TEXT_MIME_TYPES: &[&str] = &[
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"STRING",
];
const HTML_MIME_TYPES: &[&str] = &["text/html"];
const RTF_MIME_TYPES: &[&str] = &["text/rtf", "application/rtf"];
const URI_LIST_MIME_TYPES: &[&str] = &["text/uri-list"];
const PNG_MIME_TYPES: &[&str] = &["image/png"];
fn mimes_for(mime: &MimeType) -> &'static [&'static str] {
match mime {
MimeType::Text => TEXT_MIME_TYPES,
MimeType::Html => HTML_MIME_TYPES,
MimeType::Rtf => RTF_MIME_TYPES,
MimeType::UriList => URI_LIST_MIME_TYPES,
MimeType::Png => PNG_MIME_TYPES,
MimeType::Custom(_) => &[], }
}
pub enum WaylandOp {
Set {
sel: Selection,
mime: MimeType,
bytes: Vec<u8>,
},
Clear {
sel: Selection,
},
Get {
sel: Selection,
mime: MimeType,
},
Available {
sel: Selection,
},
}
pub enum WaylandOpResult {
Set(Result<(), ClipboardError>),
Clear(Result<(), ClipboardError>),
Get(Result<Vec<u8>, ClipboardError>),
Available(Result<Vec<MimeType>, ClipboardError>),
}
pub struct WaylandRequest {
pub op: WaylandOp,
pub reply: crate::reply::Reply<WaylandOpResult>,
}
pub struct WaylandFuture {
oneshot: Arc<crate::oneshot::Oneshot<WaylandOpResult>>,
}
impl std::future::Future for WaylandFuture {
type Output = WaylandOpResult;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
self.oneshot.poll(cx)
}
}
pub struct WaylandThread {
tx: mpsc::Sender<WaylandRequest>,
}
impl WaylandThread {
fn new() -> Result<Self, ClipboardError> {
let conn = WaylandConnection::open()?;
if conn.find_global(EXT_DATA_CONTROL_MANAGER).is_none() {
return Err(ClipboardError::FocusRequired);
}
if conn.find_global(WL_SEAT).is_none() {
return Err(ClipboardError::FocusRequired);
}
let (tx, rx) = mpsc::channel::<WaylandRequest>();
std::thread::Builder::new()
.name("hjkl-clipboard-wayland".into())
.spawn(move || {
let mut state = match WaylandState::init(conn) {
Ok(s) => s,
Err(e) => {
eprintln!("hjkl-clipboard wayland thread: init failed: {e}");
serve_inbox_with_errors(rx);
return;
}
};
run_loop(&mut state, rx);
})
.expect("failed to spawn Wayland bg thread");
Ok(Self { tx })
}
pub(crate) fn send_async(&self, op: WaylandOp) -> WaylandFuture {
let oneshot = crate::oneshot::Oneshot::new();
let reply = crate::reply::Reply::Async(Arc::clone(&oneshot));
if let Err(mpsc::SendError(req)) = self.tx.send(WaylandRequest { op, reply }) {
fail_request(req);
}
WaylandFuture { oneshot }
}
pub(crate) fn send_sync(&self, op: WaylandOp) -> Result<WaylandOpResult, ClipboardError> {
let pair = Arc::new((Mutex::new(None::<WaylandOpResult>), Condvar::new()));
let reply = crate::reply::Reply::Sync(Arc::clone(&pair));
self.tx
.send(WaylandRequest { op, reply })
.map_err(|_| ClipboardError::io_other("wayland thread inbox closed"))?;
let (lock, cvar) = &*pair;
let mut guard = lock.lock().unwrap();
while guard.is_none() {
guard = cvar.wait(guard).unwrap();
}
Ok(guard.take().unwrap())
}
}
static WAYLAND_THREAD: OnceLock<Result<WaylandThread, ClipboardError>> = OnceLock::new();
pub fn wayland_thread() -> Result<&'static WaylandThread, ClipboardError> {
WAYLAND_THREAD
.get_or_init(WaylandThread::new)
.as_ref()
.map_err(ClipboardError::clone)
}
struct OwnedSource {
id: u32,
payloads: HashMap<String, Vec<u8>>,
#[allow(dead_code)]
offered_mimes: Vec<String>,
}
struct PendingWrite {
fd: c_int,
payload: Vec<u8>,
written: usize,
deadline: Instant,
}
struct OfferData {
id: u32,
mimes: Vec<String>,
}
struct WaylandState {
socket: WaylandSocket,
next_id: u32,
#[allow(dead_code)]
seat_name: u32,
#[allow(dead_code)]
seat_id: u32,
#[allow(dead_code)]
manager_name: u32,
manager_id: u32,
device_id: u32,
#[allow(dead_code)]
sync_id: u32,
clipboard_source: Option<OwnedSource>,
primary_source: Option<OwnedSource>,
pending_offers: HashMap<u32, OfferData>,
current_clipboard_offer: Option<OfferData>,
current_primary_offer: Option<OfferData>,
primary_device_id: u32,
primary_manager_id: u32,
offer_ids: HashMap<u32, bool>, pending_writes: Vec<PendingWrite>,
fatal_error: bool,
}
impl WaylandState {
fn alloc_id(&mut self) -> u32 {
let id = self.next_id;
self.next_id += 1;
id
}
}
fn init_bind(
socket: &mut WaylandSocket,
next_id: &mut u32,
seat_name: u32,
seat_version: u32,
manager_name: u32,
) -> Result<(u32, u32, u32, u32), ClipboardError> {
let registry_id: u32 = 2;
let seat_id = *next_id;
*next_id += 1;
let seat_ver = seat_version.min(7);
send_registry_bind(socket, registry_id, seat_name, WL_SEAT, seat_ver, seat_id)?;
sync_or_die(socket, next_id, "after wl_seat bind")?;
let manager_id = *next_id;
*next_id += 1;
send_registry_bind(
socket,
registry_id,
manager_name,
EXT_DATA_CONTROL_MANAGER,
1,
manager_id,
)?;
sync_or_die(socket, next_id, "after ext_data_control_manager_v1 bind")?;
let device_id = *next_id;
*next_id += 1;
{
let mut args = Vec::new();
encode_u32(&mut args, device_id);
encode_u32(&mut args, seat_id);
let msg = encode_message(manager_id, EXT_MANAGER_GET_DATA_DEVICE, &args);
socket.send(&msg, &[])?;
}
let sync_id = sync_or_die(socket, next_id, "after manager.get_data_device")?;
Ok((seat_id, manager_id, device_id, sync_id))
}
fn sync_or_die(
socket: &mut WaylandSocket,
next_id: &mut u32,
phase: &str,
) -> Result<u32, ClipboardError> {
let sync_id = *next_id;
*next_id += 1;
let mut args = Vec::new();
encode_u32(&mut args, sync_id);
let msg = encode_message(WL_DISPLAY_ID, WL_DISPLAY_SYNC, &args);
socket.send(&msg, &[])?;
drain_until_sync_phased(socket, sync_id, phase)?;
Ok(sync_id)
}
fn drain_until_sync_phased(
socket: &mut WaylandSocket,
sync_id: u32,
phase: &str,
) -> Result<(), ClipboardError> {
for _ in 0..4096 {
socket.recv(true)?;
while let Some((hdr, args)) = socket.next_message() {
if hdr.object_id == sync_id && hdr.opcode == WL_CALLBACK_DONE {
return Ok(());
}
if hdr.object_id == WL_DISPLAY_ID && hdr.opcode == WL_DISPLAY_ERROR {
let msg = parse_display_error(&args);
return Err(ClipboardError::io_other(&format!(
"wl_display.error {phase}: {msg}"
)));
}
}
}
Err(ClipboardError::io_other(&format!(
"timed out waiting for bind sync callback ({phase})"
)))
}
fn send_registry_bind(
socket: &mut WaylandSocket,
registry_id: u32,
name: u32,
interface: &str,
version: u32,
new_id: u32,
) -> Result<(), ClipboardError> {
let mut args = Vec::new();
encode_u32(&mut args, name);
encode_string(&mut args, interface);
encode_u32(&mut args, version);
encode_u32(&mut args, new_id);
let msg = encode_message(registry_id, WL_REGISTRY_BIND, &args);
socket.send(&msg, &[])
}
fn parse_display_error(args: &[u8]) -> String {
let Some((obj_id, rest)) = parse_u32(args) else {
return "(malformed error event)".to_owned();
};
let Some((code, rest)) = parse_u32(rest) else {
return format!("object={obj_id} (malformed code)");
};
let msg = if let Some((s, _)) = parse_string(rest) {
s.to_owned()
} else {
"(no message)".to_owned()
};
format!("object={obj_id} code={code} msg={msg:?}")
}
impl WaylandState {
fn init(conn: WaylandConnection) -> Result<Self, ClipboardError> {
let seat_global = conn
.find_global(WL_SEAT)
.ok_or(ClipboardError::FocusRequired)?;
let seat_name = seat_global.name;
let seat_version = seat_global.version;
let manager_global = conn
.find_global(EXT_DATA_CONTROL_MANAGER)
.ok_or(ClipboardError::FocusRequired)?;
let manager_name = manager_global.name;
let primary_global = conn.find_global(ZWP_PRIMARY_SEL_MANAGER).cloned();
let (mut socket, mut next_id) = conn.into_parts();
if next_id < FIRST_CLIENT_ID {
next_id = FIRST_CLIENT_ID;
}
let (seat_id, manager_id, device_id, sync_id) = init_bind(
&mut socket,
&mut next_id,
seat_name,
seat_version,
manager_name,
)?;
let (primary_manager_id, primary_device_id) =
if let Some(pm_global) = primary_global.as_ref() {
let pm_name = pm_global.name;
let pm_id = next_id;
next_id += 1;
let pd_id = next_id;
next_id += 1;
send_registry_bind(&mut socket, 2, pm_name, ZWP_PRIMARY_SEL_MANAGER, 1, pm_id)?;
{
let mut args = Vec::new();
encode_u32(&mut args, pd_id);
encode_u32(&mut args, seat_id);
let msg = encode_message(pm_id, ZWP_PRIMARY_MANAGER_GET_DEVICE, &args);
socket.send(&msg, &[])?;
}
(pm_id, pd_id)
} else {
(0, 0)
};
Ok(Self {
socket,
next_id,
seat_name,
seat_id,
manager_name,
manager_id,
device_id,
sync_id,
clipboard_source: None,
primary_source: None,
pending_offers: HashMap::new(),
current_clipboard_offer: None,
current_primary_offer: None,
primary_device_id,
primary_manager_id,
offer_ids: HashMap::new(),
pending_writes: Vec::new(),
fatal_error: false,
})
}
}
fn run_loop(state: &mut WaylandState, rx: mpsc::Receiver<WaylandRequest>) {
loop {
let (socket_readable, writable_fds) = poll_fds(state, 50);
let now = Instant::now();
state.pending_writes.retain(|pw| {
if now > pw.deadline {
unsafe { libc::close(pw.fd) };
false
} else {
true
}
});
if !writable_fds.is_empty() {
drain_pending_writes(state, &writable_fds);
}
if socket_readable {
if let Err(e) = state.socket.recv(false) {
eprintln!("hjkl-clipboard wayland: recv error: {e}");
break;
}
dispatch_events(state);
if state.fatal_error {
break;
}
}
loop {
match rx.recv_timeout(Duration::from_millis(0)) {
Ok(req) => handle_op(state, req),
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
}
serve_inbox_with_errors(rx);
}
fn fail_request(req: WaylandRequest) {
let err = || ClipboardError::io_other("wayland thread unavailable");
let result = match req.op {
WaylandOp::Set { .. } => WaylandOpResult::Set(Err(err())),
WaylandOp::Clear { .. } => WaylandOpResult::Clear(Err(err())),
WaylandOp::Get { .. } => WaylandOpResult::Get(Err(err())),
WaylandOp::Available { .. } => WaylandOpResult::Available(Err(err())),
};
req.reply.resolve(result);
}
fn serve_inbox_with_errors(rx: mpsc::Receiver<WaylandRequest>) {
while let Ok(req) = rx.recv() {
fail_request(req);
}
}
fn poll_fds(state: &WaylandState, timeout_ms: i32) -> (bool, Vec<c_int>) {
let mut pfds: Vec<libc::pollfd> = Vec::with_capacity(1 + state.pending_writes.len());
pfds.push(libc::pollfd {
fd: state.socket.raw_fd(),
events: libc::POLLIN,
revents: 0,
});
for pw in &state.pending_writes {
pfds.push(libc::pollfd {
fd: pw.fd,
events: libc::POLLOUT,
revents: 0,
});
}
let ret = unsafe { libc::poll(pfds.as_mut_ptr(), pfds.len() as libc::nfds_t, timeout_ms) };
if ret <= 0 {
return (false, vec![]);
}
let socket_readable = (pfds[0].revents & libc::POLLIN) != 0;
let writable: Vec<c_int> = pfds[1..]
.iter()
.filter(|p| (p.revents & libc::POLLOUT) != 0)
.map(|p| p.fd)
.collect();
(socket_readable, writable)
}
fn drain_pending_writes(state: &mut WaylandState, writable_fds: &[c_int]) {
let mut i = 0;
while i < state.pending_writes.len() {
let pw = &state.pending_writes[i];
if !writable_fds.contains(&pw.fd) {
i += 1;
continue;
}
let fd = state.pending_writes[i].fd;
let done = {
let pw = &mut state.pending_writes[i];
match try_write_nonblocking(fd, &pw.payload, pw.written) {
WriteResult::Done => true,
WriteResult::Partial(n) => {
pw.written += n;
false
}
WriteResult::WouldBlock => false,
WriteResult::Error => true, }
};
if done {
let fd = state.pending_writes[i].fd;
unsafe { libc::close(fd) };
state.pending_writes.swap_remove(i);
} else {
i += 1;
}
}
}
enum WriteResult {
Done,
Partial(usize),
WouldBlock,
Error,
}
fn try_write_nonblocking(fd: c_int, data: &[u8], written: usize) -> WriteResult {
if written >= data.len() {
return WriteResult::Done;
}
let remaining = &data[written..];
let n = unsafe {
libc::write(
fd,
remaining.as_ptr() as *const libc::c_void,
remaining.len(),
)
};
if n > 0 {
let n = n as usize;
if written + n >= data.len() {
WriteResult::Done
} else {
WriteResult::Partial(n)
}
} else if n == 0 {
WriteResult::Done
} else {
let err = std::io::Error::last_os_error();
#[allow(unreachable_patterns)]
match err.raw_os_error() {
Some(libc::EAGAIN) | Some(libc::EWOULDBLOCK) => WriteResult::WouldBlock,
_ => WriteResult::Error,
}
}
}
fn dispatch_events(state: &mut WaylandState) {
let mut messages: Vec<(super::wayland_wire::MessageHeader, Vec<u8>, Option<c_int>)> =
Vec::new();
while let Some((hdr, args)) = state.socket.next_message() {
let expects_fd = (hdr.opcode == EXT_SOURCE_SEND
&& state.clipboard_source.as_ref().map(|s| s.id) == Some(hdr.object_id))
|| (hdr.opcode == ZWP_PRIMARY_SOURCE_SEND
&& state.primary_source.as_ref().map(|s| s.id) == Some(hdr.object_id));
let opt_fd = if expects_fd {
state.socket.next_fd()
} else {
None
};
messages.push((hdr, args, opt_fd));
}
for (hdr, args, opt_fd) in messages {
handle_event(state, hdr.object_id, hdr.opcode, &args, opt_fd);
}
}
fn handle_event(
state: &mut WaylandState,
object_id: u32,
opcode: u16,
args: &[u8],
opt_fd: Option<c_int>,
) {
if object_id == WL_DISPLAY_ID {
match opcode {
WL_DISPLAY_ERROR => {
let detail = parse_display_error(args);
eprintln!(
"hjkl-clipboard wayland: wl_display.error ({detail}) — terminating bg thread"
);
state.fatal_error = true;
}
WL_DISPLAY_DELETE_ID => {
if let Some((id, _)) = parse_u32(args) {
state.pending_offers.remove(&id);
state.offer_ids.remove(&id);
}
}
_ => {}
}
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
return;
}
if object_id == state.device_id {
match opcode {
EXT_DEVICE_DATA_OFFER => {
if let Some((offer_id, _)) = parse_u32(args) {
state.pending_offers.insert(
offer_id,
OfferData {
id: offer_id,
mimes: Vec::new(),
},
);
state.offer_ids.insert(offer_id, false); }
}
EXT_DEVICE_SELECTION => {
if let Some((offer_id, _)) = parse_u32(args) {
if let Some(old) = state.current_clipboard_offer.take() {
let msg = encode_message(old.id, EXT_OFFER_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
state.offer_ids.remove(&old.id);
}
if offer_id == 0 {
state.current_clipboard_offer = None;
} else {
let offer = state.pending_offers.remove(&offer_id).unwrap_or(OfferData {
id: offer_id,
mimes: Vec::new(),
});
state.current_clipboard_offer = Some(offer);
}
}
}
EXT_DEVICE_PRIMARY_SELECTION => {
if let Some((offer_id, _)) = parse_u32(args) {
if let Some(old) = state.current_primary_offer.take() {
let msg = encode_message(old.id, EXT_OFFER_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
state.offer_ids.remove(&old.id);
}
if offer_id == 0 {
state.current_primary_offer = None;
} else {
let offer = state.pending_offers.remove(&offer_id).unwrap_or(OfferData {
id: offer_id,
mimes: Vec::new(),
});
state.current_primary_offer = Some(offer);
}
}
}
EXT_DEVICE_FINISHED => {
}
_ => {}
}
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
return;
}
if state.primary_device_id != 0 && object_id == state.primary_device_id {
match opcode {
ZWP_PRIMARY_DEVICE_DATA_OFFER => {
if let Some((offer_id, _)) = parse_u32(args) {
state.pending_offers.insert(
offer_id,
OfferData {
id: offer_id,
mimes: Vec::new(),
},
);
state.offer_ids.insert(offer_id, true); }
}
ZWP_PRIMARY_DEVICE_SELECTION => {
if let Some((offer_id, _)) = parse_u32(args) {
if let Some(old) = state.current_primary_offer.take() {
let msg = encode_message(old.id, ZWP_PRIMARY_OFFER_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
state.offer_ids.remove(&old.id);
}
if offer_id == 0 {
state.current_primary_offer = None;
} else {
let offer = state.pending_offers.remove(&offer_id).unwrap_or(OfferData {
id: offer_id,
mimes: Vec::new(),
});
state.current_primary_offer = Some(offer);
}
}
}
_ => {}
}
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
return;
}
if state.offer_ids.contains_key(&object_id) {
if opcode == EXT_OFFER_OFFER
&& let Some((mime, _)) = parse_string(args)
{
if let Some(offer) = state.pending_offers.get_mut(&object_id) {
offer.mimes.push(mime.to_owned());
}
if state.current_clipboard_offer.as_ref().map(|o| o.id) == Some(object_id) {
if let Some(ref mut o) = state.current_clipboard_offer
&& !o.mimes.contains(&mime.to_owned())
{
o.mimes.push(mime.to_owned());
}
} else if state.current_primary_offer.as_ref().map(|o| o.id) == Some(object_id)
&& let Some(ref mut o) = state.current_primary_offer
&& !o.mimes.contains(&mime.to_owned())
{
o.mimes.push(mime.to_owned());
}
}
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
return;
}
let is_our_clipboard_source = state
.clipboard_source
.as_ref()
.is_some_and(|s| s.id == object_id);
if is_our_clipboard_source {
match opcode {
EXT_SOURCE_SEND => handle_source_send(state, args, opt_fd),
EXT_SOURCE_CANCELLED => handle_source_cancelled(state, opt_fd),
_ => {
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
}
return;
}
let is_our_primary_source = state
.primary_source
.as_ref()
.is_some_and(|s| s.id == object_id);
if is_our_primary_source {
match opcode {
ZWP_PRIMARY_SOURCE_SEND => handle_primary_source_send(state, args, opt_fd),
ZWP_PRIMARY_SOURCE_CANCELLED => handle_primary_source_cancelled(state, opt_fd),
_ => {
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
}
return;
}
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
fn handle_source_send(state: &mut WaylandState, args: &[u8], opt_fd: Option<c_int>) {
let Some((mime, _rest)) = parse_string(args) else {
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
return;
};
let Some(write_fd) = opt_fd else {
return;
};
let payload = state
.clipboard_source
.as_ref()
.and_then(|s| s.payloads.get(mime))
.cloned()
.unwrap_or_default();
begin_nonblocking_write(state, write_fd, payload);
}
#[cfg(test)]
fn write_to_fd(fd: c_int, data: &[u8]) {
let mut written = 0;
while written < data.len() {
let n = unsafe {
libc::write(
fd,
data[written..].as_ptr() as *const libc::c_void,
data.len() - written,
)
};
if n <= 0 {
break;
}
written += n as usize;
}
}
fn begin_nonblocking_write(state: &mut WaylandState, write_fd: c_int, payload: Vec<u8>) {
let flags = unsafe { libc::fcntl(write_fd, libc::F_GETFL) };
if flags >= 0 {
unsafe { libc::fcntl(write_fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
}
if payload.is_empty() {
unsafe { libc::close(write_fd) };
return;
}
match try_write_nonblocking(write_fd, &payload, 0) {
WriteResult::Done => {
unsafe { libc::close(write_fd) };
}
WriteResult::Partial(n) => {
state.pending_writes.push(PendingWrite {
fd: write_fd,
payload,
written: n,
deadline: Instant::now() + Duration::from_secs(5),
});
}
WriteResult::WouldBlock => {
state.pending_writes.push(PendingWrite {
fd: write_fd,
payload,
written: 0,
deadline: Instant::now() + Duration::from_secs(5),
});
}
WriteResult::Error => {
unsafe { libc::close(write_fd) };
}
}
}
fn handle_source_cancelled(state: &mut WaylandState, opt_fd: Option<c_int>) {
if let Some(source) = state.clipboard_source.take() {
let msg = encode_message(source.id, EXT_SOURCE_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
}
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
fn handle_primary_source_send(state: &mut WaylandState, args: &[u8], opt_fd: Option<c_int>) {
let Some((mime, _)) = parse_string(args) else {
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
return;
};
let Some(write_fd) = opt_fd else {
return;
};
let payload = state
.primary_source
.as_ref()
.and_then(|s| s.payloads.get(mime))
.cloned()
.unwrap_or_default();
begin_nonblocking_write(state, write_fd, payload);
}
fn handle_primary_source_cancelled(state: &mut WaylandState, opt_fd: Option<c_int>) {
if let Some(source) = state.primary_source.take() {
let msg = encode_message(source.id, ZWP_PRIMARY_SOURCE_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
}
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
fn handle_op(state: &mut WaylandState, req: WaylandRequest) {
let result = match req.op {
WaylandOp::Set { sel, mime, bytes } => {
WaylandOpResult::Set(do_set(state, sel, mime, bytes))
}
WaylandOp::Clear { sel } => WaylandOpResult::Clear(do_clear(state, sel)),
WaylandOp::Get { sel, mime } => WaylandOpResult::Get(do_get(state, sel, &mime)),
WaylandOp::Available { sel } => WaylandOpResult::Available(do_available(state, sel)),
};
req.reply.resolve(result);
}
fn do_set(
state: &mut WaylandState,
sel: Selection,
mime: MimeType,
bytes: Vec<u8>,
) -> Result<(), ClipboardError> {
if sel == Selection::Primary {
return do_set_primary(state, mime, bytes);
}
if let Some(old) = state.clipboard_source.take() {
let msg = encode_message(old.id, EXT_SOURCE_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
}
let source_id = state.alloc_id();
{
let mut args = Vec::new();
encode_u32(&mut args, source_id);
let msg = encode_message(state.manager_id, EXT_MANAGER_CREATE_DATA_SOURCE, &args);
state.socket.send(&msg, &[])?;
}
let mimes: Vec<String> = if let MimeType::Custom(ref s) = mime {
vec![s.clone()]
} else {
mimes_for(&mime).iter().map(|s| s.to_string()).collect()
};
let mut payloads: HashMap<String, Vec<u8>> = HashMap::new();
for m in &mimes {
payloads.insert(m.clone(), bytes.clone());
}
for m in &mimes {
let mut args = Vec::new();
encode_string(&mut args, m);
let msg = encode_message(source_id, EXT_SOURCE_OFFER, &args);
state.socket.send(&msg, &[])?;
}
{
let mut args = Vec::new();
encode_u32(&mut args, source_id);
let msg = encode_message(state.device_id, EXT_DEVICE_SET_SELECTION, &args);
state.socket.send(&msg, &[])?;
}
state.clipboard_source = Some(OwnedSource {
id: source_id,
payloads,
offered_mimes: mimes,
});
Ok(())
}
fn do_set_primary(
state: &mut WaylandState,
mime: MimeType,
bytes: Vec<u8>,
) -> Result<(), ClipboardError> {
if state.primary_device_id == 0 {
return Err(ClipboardError::UnsupportedMime);
}
if let Some(old) = state.primary_source.take() {
let msg = encode_message(old.id, ZWP_PRIMARY_SOURCE_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
}
let source_id = state.alloc_id();
{
let mut args = Vec::new();
encode_u32(&mut args, source_id);
let msg = encode_message(
state.primary_manager_id,
ZWP_PRIMARY_MANAGER_CREATE_SOURCE,
&args,
);
state.socket.send(&msg, &[])?;
}
let mimes: Vec<String> = if let MimeType::Custom(ref s) = mime {
vec![s.clone()]
} else {
mimes_for(&mime).iter().map(|s| s.to_string()).collect()
};
let mut payloads: HashMap<String, Vec<u8>> = HashMap::new();
for m in &mimes {
payloads.insert(m.clone(), bytes.clone());
}
for m in &mimes {
let mut args = Vec::new();
encode_string(&mut args, m);
let msg = encode_message(source_id, ZWP_PRIMARY_SOURCE_OFFER, &args);
state.socket.send(&msg, &[])?;
}
{
let mut args = Vec::new();
encode_u32(&mut args, source_id);
encode_u32(&mut args, 0); let msg = encode_message(
state.primary_device_id,
ZWP_PRIMARY_DEVICE_SET_SELECTION,
&args,
);
state.socket.send(&msg, &[])?;
}
state.primary_source = Some(OwnedSource {
id: source_id,
payloads,
offered_mimes: mimes,
});
Ok(())
}
fn do_clear(state: &mut WaylandState, sel: Selection) -> Result<(), ClipboardError> {
if sel == Selection::Primary {
return do_clear_primary(state);
}
if let Some(source) = state.clipboard_source.take() {
let msg = encode_message(source.id, EXT_SOURCE_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
}
{
let mut args = Vec::new();
encode_u32(&mut args, 0);
let msg = encode_message(state.device_id, EXT_DEVICE_SET_SELECTION, &args);
state.socket.send(&msg, &[])?;
}
Ok(())
}
fn do_clear_primary(state: &mut WaylandState) -> Result<(), ClipboardError> {
if state.primary_device_id == 0 {
return Err(ClipboardError::UnsupportedMime);
}
if let Some(source) = state.primary_source.take() {
let msg = encode_message(source.id, ZWP_PRIMARY_SOURCE_DESTROY, &[]);
let _ = state.socket.send(&msg, &[]);
}
{
let mut args = Vec::new();
encode_u32(&mut args, 0);
encode_u32(&mut args, 0);
let msg = encode_message(
state.primary_device_id,
ZWP_PRIMARY_DEVICE_SET_SELECTION,
&args,
);
state.socket.send(&msg, &[])?;
}
Ok(())
}
fn mime_str_to_type(s: &str) -> Option<MimeType> {
match s {
"text/plain;charset=utf-8" | "UTF8_STRING" | "text/plain" | "STRING" => {
Some(MimeType::Text)
}
"text/html" => Some(MimeType::Html),
"text/rtf" | "application/rtf" => Some(MimeType::Rtf),
"text/uri-list" => Some(MimeType::UriList),
"image/png" => Some(MimeType::Png),
_ => None,
}
}
fn do_get(
state: &mut WaylandState,
sel: Selection,
mime: &MimeType,
) -> Result<Vec<u8>, ClipboardError> {
if let Some(own) = match sel {
Selection::Clipboard => state.clipboard_source.as_ref(),
Selection::Primary => state.primary_source.as_ref(),
} {
let candidates: &[&str] = match mime {
MimeType::Text => &[
"text/plain;charset=utf-8",
"UTF8_STRING",
"text/plain",
"STRING",
],
MimeType::Html => &["text/html"],
MimeType::Rtf => &["text/rtf", "application/rtf"],
MimeType::UriList => &["text/uri-list"],
MimeType::Png => &["image/png"],
MimeType::Custom(s) => &[s.as_str()],
};
for c in candidates {
if let Some(bytes) = own.payloads.get(*c) {
return Ok(bytes.clone());
}
}
return Err(ClipboardError::UnsupportedMime);
}
let offer = match sel {
Selection::Clipboard => state.current_clipboard_offer.as_ref(),
Selection::Primary => state.current_primary_offer.as_ref(),
};
let offer = offer.ok_or(ClipboardError::UnsupportedMime)?;
let candidates: &[&str] = match mime {
MimeType::Text => &[
"text/plain;charset=utf-8",
"UTF8_STRING",
"text/plain",
"STRING",
],
MimeType::Html => &["text/html"],
MimeType::Rtf => &["text/rtf", "application/rtf"],
MimeType::UriList => &["text/uri-list"],
MimeType::Png => &["image/png"],
MimeType::Custom(s) => {
let found = offer.mimes.iter().any(|m| m == s.as_str());
if !found {
return Err(ClipboardError::UnsupportedMime);
}
let offer_id = offer.id;
let is_primary = sel == Selection::Primary;
return receive_from_offer(state, offer_id, s, is_primary);
}
};
let mime_str = candidates
.iter()
.find(|c| offer.mimes.iter().any(|m| m == **c))
.copied()
.ok_or(ClipboardError::UnsupportedMime)?;
let offer_id = offer.id;
let is_primary = sel == Selection::Primary;
receive_from_offer(state, offer_id, mime_str, is_primary)
}
fn receive_from_offer(
state: &mut WaylandState,
offer_id: u32,
mime_str: &str,
is_primary: bool,
) -> Result<Vec<u8>, ClipboardError> {
let mut fds = [0i32; 2];
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
if rc != 0 {
return Err(ClipboardError::io(std::io::Error::last_os_error()));
}
let read_fd = fds[0];
let write_fd = fds[1];
let receive_opcode = if is_primary {
ZWP_PRIMARY_OFFER_RECEIVE
} else {
EXT_OFFER_RECEIVE
};
let mut args = Vec::new();
encode_string(&mut args, mime_str);
let msg = encode_message(offer_id, receive_opcode, &args);
let send_result = state.socket.send(&msg, &[write_fd]);
unsafe { libc::close(write_fd) };
if let Err(e) = send_result {
unsafe { libc::close(read_fd) };
return Err(e);
}
let data = read_fd_to_end(read_fd);
unsafe { libc::close(read_fd) };
data
}
const MAX_PASTE_BYTES: usize = 256 * 1024 * 1024;
const PASTE_IDLE_TIMEOUT_MS: c_int = 2000;
fn read_fd_to_end(fd: c_int) -> Result<Vec<u8>, ClipboardError> {
let mut result = Vec::new();
let mut buf = [0u8; 4096];
loop {
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let pr = unsafe { libc::poll(&mut pfd, 1, PASTE_IDLE_TIMEOUT_MS) };
if pr < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(ClipboardError::io(err));
}
if pr == 0 {
return Err(ClipboardError::io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"clipboard paste stalled (no data from selection owner)",
)));
}
let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted
|| err.kind() == std::io::ErrorKind::WouldBlock
{
continue;
}
return Err(ClipboardError::io(err));
}
if n == 0 {
break; }
result.extend_from_slice(&buf[..n as usize]);
if result.len() > MAX_PASTE_BYTES {
return Err(ClipboardError::io(std::io::Error::other(
"clipboard paste exceeds size limit",
)));
}
}
Ok(result)
}
fn do_available(state: &mut WaylandState, sel: Selection) -> Result<Vec<MimeType>, ClipboardError> {
let offer = match sel {
Selection::Clipboard => state.current_clipboard_offer.as_ref(),
Selection::Primary => state.current_primary_offer.as_ref(),
};
let Some(offer) = offer else {
return Ok(vec![]);
};
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for mime_str in &offer.mimes {
if let Some(mt) = mime_str_to_type(mime_str) {
let key = match &mt {
MimeType::Text => 0u8,
MimeType::Html => 1,
MimeType::Rtf => 2,
MimeType::UriList => 3,
MimeType::Png => 4,
MimeType::Custom(_) => 5,
};
if seen.insert(key) {
result.push(mt);
}
}
}
Ok(result)
}
pub fn set_clipboard(
thread: &WaylandThread,
sel: Selection,
mime: &MimeType,
bytes: &[u8],
) -> Result<(), ClipboardError> {
let result = thread.send_sync(WaylandOp::Set {
sel,
mime: mime.clone(),
bytes: bytes.to_vec(),
})?;
match result {
WaylandOpResult::Set(r) => r,
_ => unreachable!(),
}
}
pub fn clear_clipboard(thread: &WaylandThread, sel: Selection) -> Result<(), ClipboardError> {
let result = thread.send_sync(WaylandOp::Clear { sel })?;
match result {
WaylandOpResult::Clear(r) => r,
_ => unreachable!(),
}
}
pub fn get_clipboard(
thread: &WaylandThread,
sel: Selection,
mime: &MimeType,
) -> Result<Vec<u8>, ClipboardError> {
let result = thread.send_sync(WaylandOp::Get {
sel,
mime: mime.clone(),
})?;
match result {
WaylandOpResult::Get(r) => r,
_ => unreachable!(),
}
}
pub fn available_clipboard(
thread: &WaylandThread,
sel: Selection,
) -> Result<Vec<MimeType>, ClipboardError> {
let result = thread.send_sync(WaylandOp::Available { sel })?;
match result {
WaylandOpResult::Available(r) => r,
_ => unreachable!(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::ffi::c_int;
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use super::super::wayland_socket::WaylandSocket;
use super::super::wayland_wire::{
encode_message, encode_string, encode_u32, parse_string, parse_u32,
};
static MOCK_SESSION: OnceLock<Option<Arc<MockCompositor>>> = OnceLock::new();
static TEST_LOCK: Mutex<()> = Mutex::new(());
pub struct MockState {
pub bound: HashMap<u32, (u32, String, u32)>,
pub source_mimes: HashMap<u32, Vec<String>>,
pub current_selection: Option<u32>,
pub paste_results: HashMap<String, Vec<u8>>,
#[allow(dead_code)]
next_server_id: u32,
pending_sends: Vec<PendingSend>,
pub cancelled_triggered: bool,
pending_clipboard_offer: Option<PendingOffer>,
pending_primary_offer: Option<PendingOffer>,
offer_payloads: HashMap<u32, HashMap<String, Vec<u8>>>,
pending_receives: Vec<(u32, String, c_int)>,
}
struct PendingOffer {
mimes: Vec<String>,
payloads: HashMap<String, Vec<u8>>,
#[allow(dead_code)]
is_primary: bool,
}
struct PendingSend {
mime: String,
#[allow(dead_code)]
read_fd: c_int,
write_fd: c_int,
source_id: u32,
complete: bool,
}
impl Default for MockState {
fn default() -> Self {
Self {
bound: HashMap::new(),
source_mimes: HashMap::new(),
current_selection: None,
paste_results: HashMap::new(),
next_server_id: 200,
pending_sends: Vec::new(),
cancelled_triggered: false,
pending_clipboard_offer: None,
pending_primary_offer: None,
offer_payloads: HashMap::new(),
pending_receives: Vec::new(),
}
}
}
impl MockState {
#[allow(dead_code)]
fn alloc_server_id(&mut self) -> u32 {
let id = self.next_server_id;
self.next_server_id += 1;
id
}
fn reset(&mut self) {
self.source_mimes.clear();
self.current_selection = None;
self.paste_results.clear();
self.pending_sends.clear();
self.cancelled_triggered = false;
self.pending_clipboard_offer = None;
self.pending_primary_offer = None;
self.offer_payloads.clear();
self.pending_receives.clear();
}
}
pub struct MockCompositor {
pub socket_path: PathBuf,
pub state: Arc<Mutex<MockState>>,
#[allow(dead_code)]
shutdown: Arc<AtomicBool>,
}
impl MockCompositor {
pub(crate) fn socket_path(&self) -> &Path {
&self.socket_path
}
pub(crate) fn state(&self) -> std::sync::MutexGuard<'_, MockState> {
self.state.lock().unwrap()
}
pub(crate) fn trigger_paste(&self, mime: &str) -> Result<Vec<u8>, std::io::Error> {
let mut fds = [0i32; 2];
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
let read_fd = fds[0];
let write_fd = fds[1];
{
let mut st = self.state.lock().unwrap();
let source_id = st.current_selection.unwrap_or(0);
st.pending_sends.push(PendingSend {
mime: mime.to_owned(),
read_fd,
write_fd,
source_id,
complete: false,
});
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
{
let st = self.state.lock().unwrap();
if st.pending_sends.iter().all(|p| p.complete) {
break;
}
}
if std::time::Instant::now() > deadline {
return Err(std::io::Error::other("trigger_paste timed out"));
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let mut result = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = unsafe {
libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
};
if n <= 0 {
break;
}
result.extend_from_slice(&buf[..n as usize]);
}
unsafe { libc::close(read_fd) };
{
let mut st = self.state.lock().unwrap();
st.paste_results.insert(mime.to_owned(), result.clone());
}
Ok(result)
}
pub(crate) fn advertise_clipboard_offer(
&self,
mimes: Vec<String>,
payloads: HashMap<String, Vec<u8>>,
) {
let mut st = self.state.lock().unwrap();
st.pending_clipboard_offer = Some(PendingOffer {
mimes,
payloads,
is_primary: false,
});
}
pub(crate) fn advertise_primary_offer(
&self,
mimes: Vec<String>,
payloads: HashMap<String, Vec<u8>>,
) {
let mut st = self.state.lock().unwrap();
st.pending_primary_offer = Some(PendingOffer {
mimes,
payloads,
is_primary: true,
});
}
#[allow(dead_code)]
pub(crate) fn wait_for_clipboard_offer(&self, timeout_ms: u64) {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
std::thread::sleep(std::time::Duration::from_millis(10));
if std::time::Instant::now() > deadline {
break;
}
}
}
#[allow(dead_code)]
pub(crate) fn shutdown(self) {
self.shutdown.store(true, Ordering::Relaxed);
}
}
pub fn spawn_mock_compositor(advertise_data_control: bool) -> Arc<MockCompositor> {
spawn_mock_compositor_with_primary(advertise_data_control, true)
}
pub fn spawn_mock_compositor_with_primary(
advertise_data_control: bool,
advertise_primary: bool,
) -> Arc<MockCompositor> {
let socket_path = PathBuf::from(format!("/tmp/hjkl-clipboard-mock-{}.sock", unsafe {
libc::getpid()
}));
let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path).expect("failed to bind mock socket");
let state = Arc::new(Mutex::new(MockState::default()));
let shutdown = Arc::new(AtomicBool::new(false));
let state_clone = Arc::clone(&state);
let shutdown_clone = Arc::clone(&shutdown);
let socket_path_clone = socket_path.clone();
std::thread::Builder::new()
.name("hjkl-mock-compositor".into())
.spawn(move || {
run_mock_compositor(
listener,
state_clone,
shutdown_clone,
advertise_data_control,
advertise_primary,
socket_path_clone,
);
})
.expect("failed to spawn mock compositor thread");
Arc::new(MockCompositor {
socket_path,
state,
shutdown,
})
}
#[derive(Debug, Clone, PartialEq)]
enum MockObjectType {
Display,
Registry,
Callback,
Seat,
DataControlManager,
DataControlDevice,
DataControlSource,
DataControlOffer,
PrimaryManager,
PrimaryDevice,
PrimarySource,
PrimaryOffer,
}
struct MockServer {
socket: WaylandSocket,
objects: HashMap<u32, MockObjectType>,
next_id: u32,
state: Arc<Mutex<MockState>>,
#[allow(dead_code)]
advertise_data_control: bool,
#[allow(dead_code)]
advertise_primary: bool,
globals: Vec<(u32, &'static str, u32)>,
device_obj_id: u32,
primary_device_obj_id: u32,
}
impl MockServer {
#[allow(dead_code)]
fn alloc_id(&mut self) -> u32 {
let id = self.next_id;
self.next_id += 1;
id
}
fn send(&self, object_id: u32, opcode: u16, args: &[u8]) {
let msg = encode_message(object_id, opcode, args);
let _ = self.socket.send(&msg, &[]);
}
#[allow(dead_code)]
fn send_with_fd(&self, object_id: u32, opcode: u16, args: &[u8], fd: c_int) {
let msg = encode_message(object_id, opcode, args);
let _ = self.socket.send(&msg, &[fd]);
}
}
fn run_mock_compositor(
listener: UnixListener,
state: Arc<Mutex<MockState>>,
shutdown: Arc<AtomicBool>,
advertise_data_control: bool,
advertise_primary: bool,
_socket_path: PathBuf,
) {
listener.set_nonblocking(true).ok();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let (stream, _) = loop {
match listener.accept() {
Ok(pair) => break pair,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if shutdown.load(Ordering::Relaxed) || std::time::Instant::now() > deadline {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
continue;
}
Err(_) => return,
}
};
use std::os::unix::io::IntoRawFd;
let raw_fd = stream.into_raw_fd();
let socket = unsafe { WaylandSocket::from_raw_fd(raw_fd) };
let mut globals: Vec<(u32, &'static str, u32)> = vec![(1, WL_SEAT, 7)];
if advertise_data_control {
globals.push((2, EXT_DATA_CONTROL_MANAGER, 1));
}
if advertise_primary {
globals.push((3, ZWP_PRIMARY_SEL_MANAGER, 1));
}
let mut server = MockServer {
socket,
objects: HashMap::new(),
next_id: 300,
state,
advertise_data_control,
advertise_primary,
globals,
device_obj_id: 0,
primary_device_obj_id: 0,
};
server.objects.insert(1, MockObjectType::Display);
server.objects.insert(2, MockObjectType::Registry);
run_mock_server_loop(&mut server, shutdown);
}
fn run_mock_server_loop(server: &mut MockServer, shutdown: Arc<AtomicBool>) {
loop {
if shutdown.load(Ordering::Relaxed) {
return;
}
dispatch_pending_offers(server);
dispatch_pending_receives(server);
dispatch_pending_sends(server);
if let Err(e) = server.socket.recv(false) {
let err_str = e.to_string();
if err_str.contains("closed") || err_str.contains("reset") {
return;
}
break;
}
while let Some((hdr, args)) = server.socket.next_message() {
let opt_fd = server.socket.next_fd();
handle_mock_message(server, hdr.object_id, hdr.opcode, &args, opt_fd);
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
fn dispatch_pending_offers(server: &mut MockServer) {
let (clipboard_offer, primary_offer) = {
let mut st = server.state.lock().unwrap();
(
st.pending_clipboard_offer.take(),
st.pending_primary_offer.take(),
)
};
if let Some(offer) = clipboard_offer {
if server.device_obj_id == 0 {
server.state.lock().unwrap().pending_clipboard_offer = Some(offer);
return;
}
let offer_id = server.next_id;
server.next_id += 1;
server
.objects
.insert(offer_id, MockObjectType::DataControlOffer);
{
let mut st = server.state.lock().unwrap();
st.offer_payloads.insert(offer_id, offer.payloads);
}
{
let mut args = Vec::new();
encode_u32(&mut args, offer_id);
server.send(server.device_obj_id, EXT_DEVICE_DATA_OFFER, &args);
}
for mime in &offer.mimes {
let mut args = Vec::new();
encode_string(&mut args, mime);
server.send(offer_id, EXT_OFFER_OFFER, &args);
}
{
let mut args = Vec::new();
encode_u32(&mut args, offer_id);
server.send(server.device_obj_id, EXT_DEVICE_SELECTION, &args);
}
}
if let Some(offer) = primary_offer {
let dev_id = server.primary_device_obj_id;
if dev_id == 0 {
server.state.lock().unwrap().pending_primary_offer = Some(offer);
return;
}
let offer_id = server.next_id;
server.next_id += 1;
server
.objects
.insert(offer_id, MockObjectType::PrimaryOffer);
{
let mut st = server.state.lock().unwrap();
st.offer_payloads.insert(offer_id, offer.payloads);
}
{
let mut args = Vec::new();
encode_u32(&mut args, offer_id);
server.send(dev_id, ZWP_PRIMARY_DEVICE_DATA_OFFER, &args);
}
for mime in &offer.mimes {
let mut args = Vec::new();
encode_string(&mut args, mime);
server.send(offer_id, ZWP_PRIMARY_OFFER_OFFER, &args);
}
{
let mut args = Vec::new();
encode_u32(&mut args, offer_id);
server.send(dev_id, ZWP_PRIMARY_DEVICE_SELECTION, &args);
}
}
}
fn dispatch_pending_receives(server: &mut MockServer) {
let work: Vec<(u32, String, c_int)> = {
let mut st = server.state.lock().unwrap();
st.pending_receives.drain(..).collect()
};
for (offer_id, mime, write_fd) in work {
let payload = {
let st = server.state.lock().unwrap();
st.offer_payloads
.get(&offer_id)
.and_then(|m| m.get(&mime))
.cloned()
.unwrap_or_default()
};
write_to_fd(write_fd, &payload);
unsafe { libc::close(write_fd) };
}
}
fn dispatch_pending_sends(server: &mut MockServer) {
let work: Vec<(u32, String, c_int)> = {
let mut st = server.state.lock().unwrap();
let mut items = Vec::new();
for pending in st.pending_sends.iter_mut() {
if !pending.complete {
items.push((pending.source_id, pending.mime.clone(), pending.write_fd));
pending.complete = true;
pending.write_fd = -1; }
}
items
};
for (source_id, mime, write_fd) in work {
let mut args = Vec::new();
encode_string(&mut args, &mime);
let msg = encode_message(source_id, EXT_SOURCE_SEND, &args);
let _ = server.socket.send(&msg, &[write_fd]);
unsafe { libc::close(write_fd) };
}
}
fn handle_mock_message(
server: &mut MockServer,
object_id: u32,
opcode: u16,
args: &[u8],
opt_fd: Option<c_int>,
) {
let obj_type = server.objects.get(&object_id).cloned();
match obj_type {
Some(MockObjectType::Display) => handle_mock_display(server, opcode, args),
Some(MockObjectType::Registry) => handle_mock_registry(server, opcode, args),
Some(MockObjectType::Callback) => {
}
Some(MockObjectType::Seat) => {
}
Some(MockObjectType::DataControlManager) => handle_mock_manager(server, opcode, args),
Some(MockObjectType::DataControlDevice) => {
handle_mock_device(server, object_id, opcode, args)
}
Some(MockObjectType::DataControlSource) => {
handle_mock_source(server, object_id, opcode, args)
}
Some(MockObjectType::DataControlOffer) => {
handle_mock_offer(server, object_id, opcode, args, opt_fd, false)
}
Some(MockObjectType::PrimaryManager) => {
handle_mock_primary_manager(server, opcode, args)
}
Some(MockObjectType::PrimaryDevice) => {
handle_mock_primary_device(server, object_id, opcode, args)
}
Some(MockObjectType::PrimarySource) => {
handle_mock_primary_source(server, object_id, opcode, args)
}
Some(MockObjectType::PrimaryOffer) => {
handle_mock_offer(server, object_id, opcode, args, opt_fd, true)
}
None => {
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
}
}
fn handle_mock_display(server: &mut MockServer, opcode: u16, args: &[u8]) {
match opcode {
0 => {
if let Some((callback_id, _)) = parse_u32(args) {
server.objects.insert(callback_id, MockObjectType::Callback);
let mut done_args = Vec::new();
encode_u32(&mut done_args, 0u32); server.send(callback_id, 0, &done_args);
}
}
1 => {
if let Some((registry_id, _)) = parse_u32(args) {
server.objects.insert(registry_id, MockObjectType::Registry);
for (name, interface, version) in &server.globals.clone() {
let mut ga = Vec::new();
encode_u32(&mut ga, *name);
encode_string(&mut ga, interface);
encode_u32(&mut ga, *version);
server.send(registry_id, 0, &ga);
}
}
}
_ => {}
}
}
fn handle_mock_registry(server: &mut MockServer, opcode: u16, args: &[u8]) {
if opcode != 0 {
return; }
let Some((name, rest)) = parse_u32(args) else {
return;
};
let Some((interface, rest)) = parse_string(rest) else {
return;
};
let Some((version, rest)) = parse_u32(rest) else {
return;
};
let Some((new_id, _)) = parse_u32(rest) else {
return;
};
let obj_type = match interface {
"wl_seat" => MockObjectType::Seat,
"ext_data_control_manager_v1" => MockObjectType::DataControlManager,
"zwp_primary_selection_device_manager_v1" => MockObjectType::PrimaryManager,
_ => return,
};
server.objects.insert(new_id, obj_type);
let mut st = server.state.lock().unwrap();
st.bound
.insert(new_id, (name, interface.to_owned(), version));
}
fn handle_mock_manager(server: &mut MockServer, opcode: u16, args: &[u8]) {
match opcode {
0 => {
if let Some((new_id, _)) = parse_u32(args) {
server
.objects
.insert(new_id, MockObjectType::DataControlSource);
let mut st = server.state.lock().unwrap();
st.source_mimes.insert(new_id, Vec::new());
}
}
1 => {
if let Some((new_id, _)) = parse_u32(args) {
server
.objects
.insert(new_id, MockObjectType::DataControlDevice);
server.device_obj_id = new_id;
}
}
2 => {
}
_ => {}
}
}
fn handle_mock_device(server: &mut MockServer, _object_id: u32, opcode: u16, args: &[u8]) {
match opcode {
0 => {
if let Some((source_id, _)) = parse_u32(args) {
let mut st = server.state.lock().unwrap();
if source_id == 0 {
st.current_selection = None;
} else {
st.current_selection = Some(source_id);
}
}
}
1 => {
}
2 => {
let _ = args;
}
_ => {}
}
}
fn handle_mock_offer(
server: &mut MockServer,
object_id: u32,
opcode: u16,
args: &[u8],
opt_fd: Option<c_int>,
_is_primary: bool,
) {
match opcode {
0 => {
if let Some((mime, _)) = parse_string(args) {
if let Some(fd) = opt_fd {
let mut st = server.state.lock().unwrap();
st.pending_receives.push((object_id, mime.to_owned(), fd));
}
} else if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
1 => {
server.objects.remove(&object_id);
server
.state
.lock()
.unwrap()
.offer_payloads
.remove(&object_id);
}
_ => {
if let Some(fd) = opt_fd {
unsafe { libc::close(fd) };
}
}
}
}
fn handle_mock_primary_manager(server: &mut MockServer, opcode: u16, args: &[u8]) {
match opcode {
0 => {
if let Some((new_id, _)) = parse_u32(args) {
server.objects.insert(new_id, MockObjectType::PrimarySource);
let mut st = server.state.lock().unwrap();
st.source_mimes.insert(new_id, Vec::new());
}
}
1 => {
if let Some((new_id, _)) = parse_u32(args) {
server.objects.insert(new_id, MockObjectType::PrimaryDevice);
server.primary_device_obj_id = new_id;
}
}
2 => {
}
_ => {}
}
}
fn handle_mock_primary_device(
server: &mut MockServer,
_object_id: u32,
opcode: u16,
args: &[u8],
) {
match opcode {
0 => {
if let Some((source_id, _)) = parse_u32(args) {
let mut st = server.state.lock().unwrap();
if source_id == 0 {
st.current_selection = None;
} else {
st.current_selection = Some(source_id);
}
}
}
1 => {
}
_ => {}
}
}
fn handle_mock_primary_source(
server: &mut MockServer,
object_id: u32,
opcode: u16,
args: &[u8],
) {
match opcode {
0 => {
if let Some((mime, _)) = parse_string(args) {
let mut st = server.state.lock().unwrap();
st.source_mimes
.entry(object_id)
.or_default()
.push(mime.to_owned());
}
}
1 => {
server.objects.remove(&object_id);
}
_ => {}
}
}
fn handle_mock_source(server: &mut MockServer, object_id: u32, opcode: u16, args: &[u8]) {
match opcode {
0 => {
if let Some((mime, _)) = parse_string(args) {
let mut st = server.state.lock().unwrap();
st.source_mimes
.entry(object_id)
.or_default()
.push(mime.to_owned());
}
}
1 => {
server.objects.remove(&object_id);
}
_ => {}
}
}
fn ensure_mock() -> Option<Arc<MockCompositor>> {
MOCK_SESSION
.get_or_init(|| {
let mock = spawn_mock_compositor(true);
let path = mock.socket_path().to_str().unwrap().to_owned();
unsafe { std::env::set_var("WAYLAND_DISPLAY", &path) };
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if mock.socket_path().exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
std::thread::sleep(std::time::Duration::from_millis(20));
let _ = wayland_thread();
Some(mock)
})
.as_ref()
.cloned()
}
fn get_thread_for_test() -> Option<&'static WaylandThread> {
ensure_mock()?;
match wayland_thread() {
Ok(t) => Some(t),
Err(e) => {
eprintln!("SKIP: wayland_thread failed: {e}");
None
}
}
}
#[test]
fn mock_compositor_set_then_paste_text() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let payload = b"hello wayland 6b";
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, payload)
.expect("set_clipboard failed");
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(
mock.state().current_selection.is_some(),
"mock should have a current_selection after set"
);
let received = mock
.trigger_paste("text/plain;charset=utf-8")
.expect("trigger_paste failed");
assert_eq!(received, payload, "pasted bytes should match what was set");
}
#[test]
fn mock_compositor_clear_unsets_selection() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
set_clipboard(
thread,
Selection::Clipboard,
&MimeType::Text,
b"to-be-cleared",
)
.expect("set failed");
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
mock.state().current_selection.is_some(),
"selection should be set"
);
clear_clipboard(thread, Selection::Clipboard).expect("clear failed");
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
mock.state().current_selection.is_none(),
"selection should be cleared"
);
}
#[test]
fn mock_compositor_offer_html() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let html = b"<b>bold</b>";
set_clipboard(thread, Selection::Clipboard, &MimeType::Html, html)
.expect("set html failed");
std::thread::sleep(std::time::Duration::from_millis(50));
let received = mock
.trigger_paste("text/html")
.expect("trigger_paste html failed");
assert_eq!(received, html, "html paste mismatch");
}
#[test]
fn mock_compositor_replace_selection() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, b"hello")
.expect("set hello failed");
std::thread::sleep(std::time::Duration::from_millis(50));
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, b"world")
.expect("set world failed");
std::thread::sleep(std::time::Duration::from_millis(50));
let received = mock
.trigger_paste("text/plain;charset=utf-8")
.expect("trigger_paste failed");
assert_eq!(received, b"world", "expected replaced selection");
}
#[test]
fn mock_get_clipboard_text() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let _ = clear_clipboard(thread, Selection::Clipboard);
let mut payloads = HashMap::new();
payloads.insert("text/plain;charset=utf-8".to_owned(), b"hello".to_vec());
mock.advertise_clipboard_offer(vec!["text/plain;charset=utf-8".to_owned()], payloads);
std::thread::sleep(std::time::Duration::from_millis(200));
let result = get_clipboard(thread, Selection::Clipboard, &MimeType::Text);
let bytes = result.expect("get should succeed");
assert_eq!(bytes, b"hello", "get returned wrong bytes");
}
#[test]
fn mock_get_clipboard_html() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let _ = clear_clipboard(thread, Selection::Clipboard);
let html = b"<b>x</b>";
let mut payloads = HashMap::new();
payloads.insert("text/html".to_owned(), html.to_vec());
mock.advertise_clipboard_offer(vec!["text/html".to_owned()], payloads);
std::thread::sleep(std::time::Duration::from_millis(200));
let bytes = get_clipboard(thread, Selection::Clipboard, &MimeType::Html)
.expect("get html should succeed");
assert_eq!(bytes, html, "html content mismatch");
}
#[test]
fn mock_available_lists_mimes() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let mut payloads = HashMap::new();
payloads.insert("text/plain;charset=utf-8".to_owned(), b"text".to_vec());
payloads.insert("text/html".to_owned(), b"<b>html</b>".to_vec());
mock.advertise_clipboard_offer(
vec![
"text/plain;charset=utf-8".to_owned(),
"text/html".to_owned(),
],
payloads,
);
std::thread::sleep(std::time::Duration::from_millis(200));
let mimes =
available_clipboard(thread, Selection::Clipboard).expect("available should succeed");
assert!(mimes.contains(&MimeType::Text), "should have Text");
assert!(mimes.contains(&MimeType::Html), "should have Html");
}
#[test]
fn mock_get_unowned_returns_unsupported() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let _ = clear_clipboard(thread, Selection::Clipboard);
let mut payloads = HashMap::new();
payloads.insert("text/html".to_owned(), b"html".to_vec());
mock.advertise_clipboard_offer(vec!["text/html".to_owned()], payloads);
std::thread::sleep(std::time::Duration::from_millis(200));
let result = get_clipboard(thread, Selection::Clipboard, &MimeType::Text);
assert!(
matches!(result, Err(ClipboardError::UnsupportedMime)),
"expected UnsupportedMime, got: {result:?}"
);
}
#[test]
fn self_paste_after_set_does_not_deadlock() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let _ = clear_clipboard(thread, Selection::Clipboard);
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, b"self-paste")
.expect("set should succeed");
let bytes = get_clipboard(thread, Selection::Clipboard, &MimeType::Text)
.expect("self-paste get should succeed");
assert_eq!(bytes, b"self-paste");
}
#[test]
fn mock_available_no_offer_returns_empty() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let result = available_clipboard(thread, Selection::Primary)
.expect("available primary should succeed");
let _ = result;
let mimes = available_clipboard(thread, Selection::Clipboard)
.expect("available clipboard should succeed");
assert!(mimes.len() <= 5, "sanity: not too many mimes");
}
#[test]
fn mock_primary_advertise_then_get() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(mock) = ensure_mock() else { return };
let Some(thread) = get_thread_for_test() else {
return;
};
mock.state().reset();
let mut payloads = HashMap::new();
payloads.insert(
"text/plain;charset=utf-8".to_owned(),
b"primary-text".to_vec(),
);
mock.advertise_primary_offer(vec!["text/plain;charset=utf-8".to_owned()], payloads);
std::thread::sleep(std::time::Duration::from_millis(300));
let result = get_clipboard(thread, Selection::Primary, &MimeType::Text);
match result {
Ok(bytes) => {
assert_eq!(bytes, b"primary-text", "primary text mismatch");
}
Err(ClipboardError::UnsupportedMime) => {
eprintln!("SKIP: primary selection not bound (UnsupportedMime)");
}
Err(e) => panic!("unexpected error: {e}"),
}
}
#[test]
fn nonblocking_send_queues_on_full_pipe_instead_of_blocking() {
use super::super::wayland_socket::WaylandSocket;
use super::super::wayland_wire;
let mut sv = [0i32; 2];
let rc = unsafe {
libc::socketpair(
libc::AF_UNIX,
libc::SOCK_STREAM | libc::SOCK_CLOEXEC,
0,
sv.as_mut_ptr(),
)
};
assert_eq!(rc, 0, "socketpair failed");
unsafe { libc::close(sv[1]) };
let socket = unsafe { WaylandSocket::from_raw_fd(sv[0]) };
let mut state = WaylandState {
socket,
next_id: 100,
seat_name: 0,
seat_id: 0,
manager_name: 0,
manager_id: 0,
device_id: 0,
sync_id: 0,
clipboard_source: None,
primary_source: None,
pending_offers: HashMap::new(),
current_clipboard_offer: None,
current_primary_offer: None,
primary_device_id: 0,
primary_manager_id: 0,
offer_ids: HashMap::new(),
pending_writes: Vec::new(),
fatal_error: false,
};
let mut fds = [0i32; 2];
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
assert_eq!(rc, 0, "pipe2 failed");
let read_fd = fds[0];
let write_fd = fds[1];
let payload = vec![0xABu8; 256 * 1024];
let start = std::time::Instant::now();
begin_nonblocking_write(&mut state, write_fd, payload.clone());
let elapsed = start.elapsed();
unsafe { libc::close(read_fd) };
assert!(
elapsed < std::time::Duration::from_millis(500),
"begin_nonblocking_write blocked for {elapsed:?} — deadlock regression"
);
assert!(
!state.pending_writes.is_empty(),
"expected a PendingWrite queued for the blocked pipe write"
);
for pw in state.pending_writes.drain(..) {
unsafe { libc::close(pw.fd) };
}
let _ = wayland_wire::encode_u32;
}
#[test]
fn wl_display_error_marks_state_fatal() {
let mut sv = [0i32; 2];
let rc = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sv.as_mut_ptr()) };
assert_eq!(rc, 0, "socketpair failed");
unsafe { libc::close(sv[1]) };
let socket = unsafe { WaylandSocket::from_raw_fd(sv[0]) };
let mut state = WaylandState {
socket,
next_id: 100,
seat_name: 0,
seat_id: 0,
manager_name: 0,
manager_id: 0,
device_id: 0,
sync_id: 0,
clipboard_source: None,
primary_source: None,
pending_offers: HashMap::new(),
current_clipboard_offer: None,
current_primary_offer: None,
primary_device_id: 0,
primary_manager_id: 0,
offer_ids: HashMap::new(),
pending_writes: Vec::new(),
fatal_error: false,
};
assert!(!state.fatal_error);
handle_event(&mut state, WL_DISPLAY_ID, WL_DISPLAY_ERROR, &[], None);
assert!(
state.fatal_error,
"wl_display.error must mark the state as fatal"
);
}
}