use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::{Arc, Condvar, Mutex, OnceLock, mpsc};
use std::time::{Duration, Instant};
use crate::{ClipboardError, MimeType, Selection};
use super::x11::{Atoms, X11Connection};
const XCB_WINDOW_CLASS_INPUT_OUTPUT: u16 = 1;
const XCB_PROP_MODE_REPLACE: u8 = 0;
const XCB_SELECTION_CLEAR: u8 = 29;
const XCB_SELECTION_REQUEST: u8 = 30;
const XCB_SELECTION_NOTIFY: u8 = 31;
const XCB_PROPERTY_NOTIFY: u8 = 28;
const XCB_PROPERTY_NEW_VALUE: u8 = 0;
const XCB_PROPERTY_DELETE: u8 = 1;
const XCB_NONE: u32 = 0;
const XCB_CURRENT_TIME: u32 = 0;
const XCB_ATOM_ATOM: u32 = 4;
const XCB_CW_EVENT_MASK: u32 = 0x800;
const XCB_EVENT_MASK_PROPERTY_CHANGE: u32 = 0x0040_0000;
const XCB_GET_PROPERTY_TYPE_ANY: u32 = 0;
const XCB_REQUEST_HEADER_OVERHEAD: usize = 24;
const EVENT_LOOP_TICK_MS: u64 = 50;
const SELECTION_NOTIFY_TIMEOUT_SECS: u64 = 5;
const INCR_RECV_CHUNK_TIMEOUT_SECS: u64 = 30;
const INCR_RECV_TOTAL_TIMEOUT_SECS: u64 = 60;
const MAX_INCR_TOTAL_BYTES: usize = 256 * 1024 * 1024;
const SAVE_TARGETS_TIMEOUT_SECS: u64 = 5;
const INCR_SEND_TOTAL_TIMEOUT_SECS: u64 = 30;
#[derive(Clone, Copy)]
#[repr(C)]
struct SelectionRequestEvent {
response_type: u8,
pad0: u8,
sequence: u16,
time: u32,
owner: u32,
requestor: u32,
selection: u32,
target: u32,
property: u32,
}
#[derive(Clone, Copy)]
#[repr(C)]
struct SelectionClearEvent {
response_type: u8,
pad0: u8,
sequence: u16,
time: u32,
owner: u32,
selection: u32,
}
#[derive(Clone, Copy)]
#[repr(C)]
struct SelectionNotifyEvent {
response_type: u8,
pad0: u8,
sequence: u16,
time: u32,
requestor: u32,
selection: u32,
target: u32,
property: u32,
}
#[derive(Clone, Copy)]
#[repr(C)]
struct PropertyNotifyEvent {
response_type: u8,
pad0: u8,
sequence: u16,
window: u32,
atom: u32,
time: u32,
state: u8,
pad1: [u8; 3],
}
struct OwnedData {
payloads: HashMap<u32, Vec<u8>>,
targets: Vec<u32>,
}
struct IncrSend {
requestor: u32,
property: u32,
target_atom: u32,
bytes: Vec<u8>,
offset: usize,
chunk_size: usize,
deadline: Instant,
done: bool,
}
struct X11State {
conn: X11Connection,
window: u32,
owned: HashMap<u32, OwnedData>,
incr_sends: Vec<IncrSend>,
custom_atoms: HashMap<String, u32>,
}
pub enum X11Op {
Set {
sel_atom: u32,
mime_atom: u32,
mime_name: Option<String>,
bytes: Vec<u8>,
},
Clear {
sel_atom: u32,
},
Get {
sel_atom: u32,
mime_atom: u32,
mime_name: Option<String>,
},
Available {
sel_atom: u32,
},
}
pub enum X11OpResult {
Set(Result<(), ClipboardError>),
Clear(Result<(), ClipboardError>),
Get(Result<Vec<u8>, ClipboardError>),
Available(Result<Vec<u32>, ClipboardError>),
}
pub struct X11Request {
pub op: X11Op,
pub reply: crate::reply::Reply<X11OpResult>,
}
pub struct X11Future {
oneshot: Arc<crate::oneshot::Oneshot<X11OpResult>>,
}
impl std::future::Future for X11Future {
type Output = X11OpResult;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
self.oneshot.poll(cx)
}
}
pub struct X11Thread {
tx: mpsc::Sender<X11Request>,
pub(crate) atoms: Atoms,
}
impl X11Thread {
fn new() -> Result<Self, ClipboardError> {
let conn = X11Connection::open()?;
let atoms = *conn.atoms();
let (tx, rx) = mpsc::channel::<X11Request>();
std::thread::Builder::new()
.name("hjkl-clipboard-x11".into())
.spawn(move || {
let window = match create_selection_window(&conn) {
Ok(w) => w,
Err(e) => {
eprintln!("hjkl-clipboard x11 thread: window creation failed: {e}");
while let Ok(req) = rx.recv() {
fail_request(req);
}
return;
}
};
let mut state = X11State {
conn,
window,
owned: HashMap::new(),
incr_sends: Vec::new(),
custom_atoms: HashMap::new(),
};
run_loop(&mut state, rx);
})
.expect("failed to spawn X11 bg thread");
Ok(Self { tx, atoms })
}
pub(crate) fn send_async(&self, op: X11Op) -> X11Future {
let oneshot = crate::oneshot::Oneshot::new();
let reply = crate::reply::Reply::Async(Arc::clone(&oneshot));
if let Err(mpsc::SendError(req)) = self.tx.send(X11Request { op, reply }) {
fail_request(req);
}
X11Future { oneshot }
}
pub(crate) fn send_sync(&self, op: X11Op) -> Result<X11OpResult, ClipboardError> {
let pair = Arc::new((Mutex::new(None::<X11OpResult>), Condvar::new()));
let reply = crate::reply::Reply::Sync(Arc::clone(&pair));
self.tx
.send(X11Request { op, reply })
.map_err(|_| ClipboardError::io_other("x11 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 X11_THREAD: OnceLock<Result<X11Thread, ClipboardError>> = OnceLock::new();
pub fn x11_thread() -> Result<&'static X11Thread, ClipboardError> {
X11_THREAD
.get_or_init(X11Thread::new)
.as_ref()
.map_err(ClipboardError::clone)
}
fn create_selection_window(conn: &X11Connection) -> Result<u32, ClipboardError> {
let fns = conn.fns();
let raw = conn.raw();
let screen = conn.screen();
let wid = unsafe { (fns.xcb_generate_id)(raw) };
let value_mask: u32 = XCB_CW_EVENT_MASK;
let value_list: [u32; 1] = [XCB_EVENT_MASK_PROPERTY_CHANGE];
unsafe {
(fns.xcb_create_window)(
raw,
screen.root_depth,
wid,
screen.root,
0, 0, 1, 1, 0, XCB_WINDOW_CLASS_INPUT_OUTPUT,
screen.root_visual,
value_mask,
value_list.as_ptr().cast::<c_void>(),
)
};
unsafe { (fns.xcb_flush)(raw) };
Ok(wid)
}
fn fail_request(req: X11Request) {
let err = || ClipboardError::io_other("x11 thread unavailable");
let result = match req.op {
X11Op::Set { .. } => X11OpResult::Set(Err(err())),
X11Op::Clear { .. } => X11OpResult::Clear(Err(err())),
X11Op::Get { .. } => X11OpResult::Get(Err(err())),
X11Op::Available { .. } => X11OpResult::Available(Err(err())),
};
req.reply.resolve(result);
}
fn run_loop(state: &mut X11State, rx: mpsc::Receiver<X11Request>) {
loop {
prune_expired_incr_sends(state);
drain_events(state, DrainGoal::AnyEvent);
match rx.recv_timeout(Duration::from_millis(EVENT_LOOP_TICK_MS)) {
Ok(req) => handle_op(state, req),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
enum DrainGoal {
AnyEvent,
SelectionNotify { our_property: u32 },
PropertyNotify { our_property: u32, our_window: u32 },
OwnPropertyDelete { our_property: u32, our_window: u32 },
}
enum DrainResult {
NotFound,
SelectionNotifySeen { property: u32 },
PropertyNotifySeen,
OwnPropertyDeleteSeen,
}
fn drain_events(state: &mut X11State, goal: DrainGoal) -> DrainResult {
let fns = state.conn.fns();
let raw = state.conn.raw();
loop {
let ev = unsafe { (fns.xcb_poll_for_event)(raw) };
if ev.is_null() {
return DrainResult::NotFound;
}
let response_type = unsafe { *(ev as *const u8) } & 0x7f;
let result = match response_type {
XCB_SELECTION_REQUEST => {
let req = unsafe { *(ev as *const SelectionRequestEvent) };
handle_selection_request(state, &req);
DrainResult::NotFound
}
XCB_SELECTION_CLEAR => {
let clr = unsafe { *(ev as *const SelectionClearEvent) };
state.owned.remove(&clr.selection);
DrainResult::NotFound
}
XCB_SELECTION_NOTIFY => {
let notify = unsafe { *(ev as *const SelectionNotifyEvent) };
match &goal {
DrainGoal::SelectionNotify { our_property }
if notify.requestor == state.window && notify.property == *our_property =>
{
unsafe { libc::free(ev.cast()) };
return DrainResult::SelectionNotifySeen {
property: notify.property,
};
}
DrainGoal::SelectionNotify { .. } if notify.requestor == state.window => {
unsafe { libc::free(ev.cast()) };
return DrainResult::SelectionNotifySeen { property: XCB_NONE };
}
_ => {
DrainResult::NotFound
}
}
}
XCB_PROPERTY_NOTIFY => {
let pn = unsafe { *(ev as *const PropertyNotifyEvent) };
if pn.state == XCB_PROPERTY_DELETE {
advance_incr_sends(state, pn.window, pn.atom);
}
match &goal {
DrainGoal::PropertyNotify {
our_property,
our_window,
} if pn.window == *our_window
&& pn.atom == *our_property
&& pn.state == XCB_PROPERTY_NEW_VALUE =>
{
unsafe { libc::free(ev.cast()) };
return DrainResult::PropertyNotifySeen;
}
DrainGoal::OwnPropertyDelete {
our_property,
our_window,
} if pn.window == *our_window
&& pn.atom == *our_property
&& pn.state == XCB_PROPERTY_DELETE =>
{
unsafe { libc::free(ev.cast()) };
return DrainResult::OwnPropertyDeleteSeen;
}
_ => DrainResult::NotFound,
}
}
_ => {
DrainResult::NotFound
}
};
unsafe { libc::free(ev.cast()) };
if !matches!(result, DrainResult::NotFound) {
return result;
}
}
}
fn handle_selection_request(state: &mut X11State, ev: &SelectionRequestEvent) {
let fns = state.conn.fns();
let raw = state.conn.raw();
let atoms = state.conn.atoms();
let max_payload = state
.conn
.screen()
.max_request_len_bytes
.saturating_sub(XCB_REQUEST_HEADER_OVERHEAD as u32) as usize;
let property = if ev.property == XCB_NONE {
ev.target
} else {
ev.property
};
let owned = state.owned.get(&ev.selection);
let reply_property = if ev.target == atoms.targets {
if let Some(data) = owned {
let mut list: Vec<u32> = Vec::with_capacity(data.targets.len() + 2);
list.push(atoms.targets);
list.push(atoms.multiple);
list.extend_from_slice(&data.targets);
unsafe {
(fns.xcb_change_property)(
raw,
XCB_PROP_MODE_REPLACE,
ev.requestor,
property,
XCB_ATOM_ATOM,
32,
list.len() as u32,
list.as_ptr().cast::<c_void>(),
);
}
property
} else {
XCB_NONE
}
} else if let Some(data) = owned {
if let Some(payload) = data.payloads.get(&ev.target) {
if payload.len() <= max_payload {
unsafe {
(fns.xcb_change_property)(
raw,
XCB_PROP_MODE_REPLACE,
ev.requestor,
property,
ev.target,
8,
payload.len() as u32,
payload.as_ptr().cast::<c_void>(),
);
}
property
} else {
let bytes = payload.clone();
let target_atom = ev.target;
send_selection_notify(state, ev, property);
start_incr_send(
state,
ev.requestor,
property,
target_atom,
bytes,
max_payload,
);
return;
}
} else {
XCB_NONE
}
} else {
XCB_NONE
};
send_selection_notify(state, ev, reply_property);
}
fn start_incr_send(
state: &mut X11State,
requestor: u32,
property: u32,
target_atom: u32,
bytes: Vec<u8>,
chunk_size: usize,
) {
let fns = state.conn.fns();
let raw = state.conn.raw();
let atoms = state.conn.atoms();
let size_hint: u32 = bytes.len().min(u32::MAX as usize) as u32;
unsafe {
(fns.xcb_change_property)(
raw,
XCB_PROP_MODE_REPLACE,
requestor,
property,
atoms.incr,
32,
1,
std::ptr::addr_of!(size_hint).cast::<c_void>(),
);
}
let event_mask_val: u32 = XCB_EVENT_MASK_PROPERTY_CHANGE;
unsafe {
(fns.xcb_change_window_attributes)(
raw,
requestor,
XCB_CW_EVENT_MASK,
std::ptr::addr_of!(event_mask_val).cast::<c_void>(),
);
}
unsafe { (fns.xcb_flush)(raw) };
state.incr_sends.push(IncrSend {
requestor,
property,
target_atom,
bytes,
offset: 0,
chunk_size,
deadline: Instant::now() + Duration::from_secs(INCR_SEND_TOTAL_TIMEOUT_SECS),
done: false,
});
}
fn prune_expired_incr_sends(state: &mut X11State) {
let now = Instant::now();
let fns = state.conn.fns();
let raw = state.conn.raw();
for xfer in &mut state.incr_sends {
if xfer.done || now < xfer.deadline {
continue;
}
unsafe {
(fns.xcb_change_property)(
raw,
XCB_PROP_MODE_REPLACE,
xfer.requestor,
xfer.property,
xfer.target_atom,
8,
0,
std::ptr::null(),
);
(fns.xcb_flush)(raw);
}
xfer.done = true;
}
state.incr_sends.retain(|x| !x.done);
}
fn advance_incr_sends(state: &mut X11State, window: u32, atom: u32) {
let fns = state.conn.fns();
let raw = state.conn.raw();
let now = Instant::now();
for xfer in &mut state.incr_sends {
if xfer.done || xfer.requestor != window || xfer.property != atom {
continue;
}
if now >= xfer.deadline {
unsafe {
(fns.xcb_change_property)(
raw,
XCB_PROP_MODE_REPLACE,
xfer.requestor,
xfer.property,
xfer.target_atom,
8,
0,
std::ptr::null(),
);
(fns.xcb_flush)(raw);
}
xfer.done = true;
continue;
}
let end = (xfer.offset + xfer.chunk_size).min(xfer.bytes.len());
let chunk = &xfer.bytes[xfer.offset..end];
unsafe {
(fns.xcb_change_property)(
raw,
XCB_PROP_MODE_REPLACE,
xfer.requestor,
xfer.property,
xfer.target_atom,
8,
chunk.len() as u32,
if chunk.is_empty() {
std::ptr::null()
} else {
chunk.as_ptr().cast::<c_void>()
},
);
(fns.xcb_flush)(raw);
}
if chunk.is_empty() {
xfer.done = true;
} else {
xfer.offset = end;
}
}
state.incr_sends.retain(|x| !x.done);
}
fn send_selection_notify(state: &mut X11State, req: &SelectionRequestEvent, property: u32) {
let fns = state.conn.fns();
let raw = state.conn.raw();
let mut buf = [0u8; 32];
buf[0] = XCB_SELECTION_NOTIFY;
buf[4..8].copy_from_slice(&req.time.to_ne_bytes());
buf[8..12].copy_from_slice(&req.requestor.to_ne_bytes());
buf[12..16].copy_from_slice(&req.selection.to_ne_bytes());
buf[16..20].copy_from_slice(&req.target.to_ne_bytes());
buf[20..24].copy_from_slice(&property.to_ne_bytes());
unsafe {
(fns.xcb_send_event)(
raw,
0, req.requestor,
0, buf.as_ptr().cast(),
);
}
unsafe { (fns.xcb_flush)(raw) };
}
fn intern_atom(state: &mut X11State, name: &str) -> Result<u32, ClipboardError> {
if let Some(&atom) = state.custom_atoms.get(name) {
return Ok(atom);
}
let fns = state.conn.fns();
let raw = state.conn.raw();
let name_len = name.len().min(u16::MAX as usize) as u16;
let cookie = unsafe {
(fns.xcb_intern_atom)(
raw,
0, name_len,
name.as_ptr() as *const std::ffi::c_char,
)
};
let reply = unsafe { (fns.xcb_intern_atom_reply)(raw, cookie, std::ptr::null_mut()) };
if reply.is_null() {
return Err(ClipboardError::io_other(
"xcb_intern_atom_reply returned null",
));
}
let atom = unsafe { (*reply).atom };
unsafe { libc::free(reply.cast()) };
state.custom_atoms.insert(name.to_owned(), atom);
Ok(atom)
}
fn resolve_mime_atom(
state: &mut X11State,
mime_atom: u32,
mime_name: Option<String>,
) -> Result<u32, ClipboardError> {
if mime_atom != 0 {
return Ok(mime_atom);
}
match mime_name {
Some(name) => intern_atom(state, &name),
None => Err(ClipboardError::UnsupportedMime),
}
}
fn handle_op(state: &mut X11State, req: X11Request) {
let result = match req.op {
X11Op::Set {
sel_atom,
mime_atom,
mime_name,
bytes,
} => {
let atom = resolve_mime_atom(state, mime_atom, mime_name);
X11OpResult::Set(atom.and_then(|a| do_set(state, sel_atom, a, bytes)))
}
X11Op::Clear { sel_atom } => X11OpResult::Clear(do_clear(state, sel_atom)),
X11Op::Get {
sel_atom,
mime_atom,
mime_name,
} => {
let atom = resolve_mime_atom(state, mime_atom, mime_name);
X11OpResult::Get(atom.and_then(|a| do_get(state, sel_atom, a)))
}
X11Op::Available { sel_atom } => X11OpResult::Available(do_available(state, sel_atom)),
};
req.reply.resolve(result);
}
fn do_set(
state: &mut X11State,
sel_atom: u32,
mime_atom: u32,
bytes: Vec<u8>,
) -> Result<(), ClipboardError> {
let fns = state.conn.fns();
let raw = state.conn.raw();
let window = state.window;
unsafe {
(fns.xcb_set_selection_owner)(raw, window, sel_atom, XCB_CURRENT_TIME);
}
unsafe { (fns.xcb_flush)(raw) };
let cookie = unsafe { (fns.xcb_get_selection_owner)(raw, sel_atom) };
let reply = unsafe { (fns.xcb_get_selection_owner_reply)(raw, cookie, std::ptr::null_mut()) };
if reply.is_null() {
return Err(ClipboardError::io_other(
"xcb_get_selection_owner_reply returned null",
));
}
let owner = unsafe { (*reply).owner };
unsafe { libc::free(reply.cast()) };
if owner != window {
return Err(ClipboardError::io_other(
"another client holds the selection",
));
}
let data = state.owned.entry(sel_atom).or_insert_with(|| OwnedData {
payloads: HashMap::new(),
targets: Vec::new(),
});
data.targets.clear();
data.payloads.clear();
data.targets.push(mime_atom);
data.payloads.insert(mime_atom, bytes);
let atoms = state.conn.atoms();
if sel_atom == atoms.clipboard {
do_save_targets(state);
}
Ok(())
}
fn do_save_targets(state: &mut X11State) {
let fns = state.conn.fns();
let raw = state.conn.raw();
let window = state.window;
let atoms = state.conn.atoms();
let cookie = unsafe { (fns.xcb_get_selection_owner)(raw, atoms.clipboard_manager) };
let reply = unsafe { (fns.xcb_get_selection_owner_reply)(raw, cookie, std::ptr::null_mut()) };
if reply.is_null() {
return; }
let mgr = unsafe { (*reply).owner };
unsafe { libc::free(reply.cast()) };
if mgr == XCB_NONE {
return;
}
let owned_atoms: Vec<u32> = state
.owned
.get(&atoms.clipboard)
.map(|d| d.targets.clone())
.unwrap_or_default();
let our_property = atoms.hjkl_clipboard_get;
unsafe {
(fns.xcb_change_property)(
raw,
XCB_PROP_MODE_REPLACE,
window,
our_property,
XCB_ATOM_ATOM,
32,
owned_atoms.len() as u32,
owned_atoms.as_ptr().cast::<c_void>(),
);
}
unsafe {
(fns.xcb_convert_selection)(
raw,
window,
atoms.clipboard_manager,
atoms.save_targets,
our_property,
XCB_CURRENT_TIME,
);
}
unsafe { (fns.xcb_flush)(raw) };
let deadline = Instant::now() + Duration::from_secs(SAVE_TARGETS_TIMEOUT_SECS);
loop {
if let DrainResult::SelectionNotifySeen { .. } =
drain_events(state, DrainGoal::SelectionNotify { our_property })
{
break;
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn do_clear(state: &mut X11State, sel_atom: u32) -> Result<(), ClipboardError> {
let fns = state.conn.fns();
let raw = state.conn.raw();
unsafe {
(fns.xcb_set_selection_owner)(raw, XCB_NONE, sel_atom, XCB_CURRENT_TIME);
}
unsafe { (fns.xcb_flush)(raw) };
state.owned.remove(&sel_atom);
Ok(())
}
fn read_property(state: &X11State) -> Result<(u32, Vec<u8>), ClipboardError> {
let fns = state.conn.fns();
let raw = state.conn.raw();
let window = state.window;
let our_property = state.conn.atoms().hjkl_clipboard_get;
let cookie = unsafe {
(fns.xcb_get_property)(
raw,
1, window,
our_property,
XCB_GET_PROPERTY_TYPE_ANY,
0, u32::MAX / 4, )
};
let reply = unsafe { (fns.xcb_get_property_reply)(raw, cookie, std::ptr::null_mut()) };
if reply.is_null() {
return Err(ClipboardError::io_other(
"xcb_get_property_reply returned null",
));
}
let type_atom = unsafe { (*reply).r#type };
let value_ptr = unsafe { (fns.xcb_get_property_value)(reply) };
let value_len = unsafe { (fns.xcb_get_property_value_length)(reply) } as usize;
let bytes = if value_len == 0 || value_ptr.is_null() {
Vec::new()
} else {
unsafe { std::slice::from_raw_parts(value_ptr as *const u8, value_len).to_vec() }
};
unsafe { libc::free(reply.cast()) };
Ok((type_atom, bytes))
}
fn do_get(state: &mut X11State, sel_atom: u32, mime_atom: u32) -> Result<Vec<u8>, ClipboardError> {
let fns = state.conn.fns();
let raw = state.conn.raw();
let window = state.window;
let our_property = state.conn.atoms().hjkl_clipboard_get;
let incr_atom = state.conn.atoms().incr;
unsafe { (fns.xcb_delete_property)(raw, window, our_property) };
unsafe {
(fns.xcb_convert_selection)(
raw,
window,
sel_atom,
mime_atom,
our_property,
XCB_CURRENT_TIME,
);
}
unsafe { (fns.xcb_flush)(raw) };
let deadline = Instant::now() + Duration::from_secs(SELECTION_NOTIFY_TIMEOUT_SECS);
let replied_property = loop {
if let DrainResult::SelectionNotifySeen { property } =
drain_events(state, DrainGoal::SelectionNotify { our_property })
{
break property;
}
if Instant::now() >= deadline {
return Err(ClipboardError::io_other(
"xcb_convert_selection timed out waiting for SELECTION_NOTIFY",
));
}
std::thread::sleep(Duration::from_millis(10));
};
if replied_property == XCB_NONE {
return Err(ClipboardError::UnsupportedMime);
}
let (type_atom, bytes) = read_property(state)?;
if type_atom != incr_atom {
return Ok(bytes);
}
{
let init_deadline = Instant::now() + Duration::from_secs(SELECTION_NOTIFY_TIMEOUT_SECS);
loop {
if let DrainResult::OwnPropertyDeleteSeen = drain_events(
state,
DrainGoal::OwnPropertyDelete {
our_property,
our_window: window,
},
) {
break;
}
if Instant::now() >= init_deadline {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
}
let mut accumulator: Vec<u8> = Vec::new();
let total_deadline = Instant::now() + Duration::from_secs(INCR_RECV_TOTAL_TIMEOUT_SECS);
loop {
let chunk_deadline = Instant::now() + Duration::from_secs(INCR_RECV_CHUNK_TIMEOUT_SECS);
loop {
if let DrainResult::PropertyNotifySeen = drain_events(
state,
DrainGoal::PropertyNotify {
our_property,
our_window: window,
},
) {
break;
}
if Instant::now() >= chunk_deadline || Instant::now() >= total_deadline {
return Err(ClipboardError::io_other(
"INCR receive timed out waiting for PROPERTY_NOTIFY",
));
}
std::thread::sleep(Duration::from_millis(10));
}
let (_type_atom, chunk) = read_property(state)?;
if chunk.is_empty() {
break;
}
accumulator.extend_from_slice(&chunk);
if accumulator.len() > MAX_INCR_TOTAL_BYTES {
return Err(ClipboardError::io_other("INCR receive exceeded size limit"));
}
if Instant::now() >= total_deadline {
return Err(ClipboardError::io_other(
"INCR receive exceeded total timeout",
));
}
}
Ok(accumulator)
}
fn do_available(state: &mut X11State, sel_atom: u32) -> Result<Vec<u32>, ClipboardError> {
let targets_atom = state.conn.atoms().targets;
let data = do_get(state, sel_atom, targets_atom);
match data {
Err(ClipboardError::UnsupportedMime) => {
Ok(vec![])
}
Err(e) => Err(e),
Ok(bytes) => {
if bytes.len() % 4 != 0 {
return Err(ClipboardError::io_other(
"TARGETS reply has non-multiple-of-4 byte length",
));
}
let atoms: Vec<u32> = bytes
.chunks_exact(4)
.map(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
.collect();
Ok(atoms)
}
}
}
pub fn mime_to_atom_static(atoms: &Atoms, mime: &MimeType) -> Option<u32> {
match mime {
MimeType::Text => Some(atoms.utf8_string),
MimeType::Html => Some(atoms.text_html),
MimeType::Rtf => Some(atoms.text_rtf),
MimeType::UriList => Some(atoms.text_uri_list),
MimeType::Png => Some(atoms.image_png),
MimeType::Custom(_) => None,
#[allow(unreachable_patterns)]
_ => None,
}
}
pub fn mime_to_atom_or_name(atoms: &Atoms, mime: &MimeType) -> (u32, Option<String>) {
match mime_to_atom_static(atoms, mime) {
Some(atom) => (atom, None),
None => {
let name = match mime {
MimeType::Custom(s) => Some(s.clone()),
_ => None,
};
(0, name)
}
}
}
pub fn atom_to_mime(atoms: &Atoms, atom: u32) -> Option<MimeType> {
if atom == atoms.utf8_string || atom == atoms.text_plain_utf8 || atom == atoms.string {
Some(MimeType::Text)
} else if atom == atoms.text_html {
Some(MimeType::Html)
} else if atom == atoms.text_rtf {
Some(MimeType::Rtf)
} else if atom == atoms.text_uri_list {
Some(MimeType::UriList)
} else if atom == atoms.image_png {
Some(MimeType::Png)
} else {
None
}
}
pub fn set_clipboard(
thread: &X11Thread,
sel: Selection,
mime: &MimeType,
bytes: &[u8],
) -> Result<(), ClipboardError> {
let (mime_atom, mime_name) = mime_to_atom_or_name(&thread.atoms, mime);
let sel_atom = sel_to_atom(&thread.atoms, sel);
let result = thread.send_sync(X11Op::Set {
sel_atom,
mime_atom,
mime_name,
bytes: bytes.to_vec(),
})?;
match result {
X11OpResult::Set(r) => r,
_ => unreachable!(),
}
}
pub fn clear_clipboard(thread: &X11Thread, sel: Selection) -> Result<(), ClipboardError> {
let sel_atom = sel_to_atom(&thread.atoms, sel);
let result = thread.send_sync(X11Op::Clear { sel_atom })?;
match result {
X11OpResult::Clear(r) => r,
_ => unreachable!(),
}
}
pub fn get_clipboard(
thread: &X11Thread,
sel: Selection,
mime: &MimeType,
) -> Result<Vec<u8>, ClipboardError> {
let (mime_atom, mime_name) = mime_to_atom_or_name(&thread.atoms, mime);
let sel_atom = sel_to_atom(&thread.atoms, sel);
let result = thread.send_sync(X11Op::Get {
sel_atom,
mime_atom,
mime_name,
})?;
match result {
X11OpResult::Get(r) => r,
_ => unreachable!(),
}
}
pub fn available_clipboard(
thread: &X11Thread,
sel: Selection,
) -> Result<Vec<MimeType>, ClipboardError> {
let sel_atom = sel_to_atom(&thread.atoms, sel);
let result = thread.send_sync(X11Op::Available { sel_atom })?;
match result {
X11OpResult::Available(r) => {
let raw_atoms = r?;
let mut mimes: Vec<MimeType> = Vec::new();
for atom in raw_atoms {
if let Some(mime) = atom_to_mime(&thread.atoms, atom)
&& !mimes.contains(&mime)
{
mimes.push(mime);
}
}
Ok(mimes)
}
_ => unreachable!(),
}
}
pub fn sel_to_atom(atoms: &Atoms, sel: Selection) -> u32 {
match sel {
Selection::Clipboard => atoms.clipboard,
Selection::Primary => atoms.primary,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{self, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
static XVFB_SESSION: OnceLock<Option<XvfbSession>> = OnceLock::new();
static TEST_LOCK: Mutex<()> = Mutex::new(());
struct XvfbSession {
_child: Child,
display: String,
}
fn ensure_xvfb() -> Option<&'static XvfbSession> {
XVFB_SESSION
.get_or_init(|| {
let xvfb_path = Path::new("/usr/bin/Xvfb");
if !xvfb_path.exists() {
eprintln!("SKIP: Xvfb not found");
return None;
}
let child = match Command::new(xvfb_path)
.args([":98", "-screen", "0", "800x600x24", "-ac"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
eprintln!("SKIP: Xvfb not found: {e}");
return None;
}
Err(e) => {
eprintln!("SKIP: failed to spawn Xvfb: {e}");
return None;
}
};
let socket = "/tmp/.X11-unix/X98";
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if UnixStream::connect(socket).is_ok() {
unsafe { std::env::set_var("DISPLAY", ":98") };
let _ = x11_thread();
return Some(XvfbSession {
_child: child,
display: ":98".into(),
});
}
std::thread::sleep(Duration::from_millis(50));
}
eprintln!("SKIP: Xvfb socket did not become connectable within 5 s");
None
})
.as_ref()
}
fn xclip(args: &[&str]) -> Option<Vec<u8>> {
if !Path::new("/usr/bin/xclip").exists() {
return None;
}
let session = ensure_xvfb()?;
let output = Command::new("/usr/bin/xclip")
.args(args)
.env("DISPLAY", &session.display)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()?;
Some(output.stdout)
}
fn xclip_clipboard() -> Option<Vec<u8>> {
xclip(&["-selection", "clipboard", "-o"])
}
fn xclip_primary() -> Option<Vec<u8>> {
xclip(&["-selection", "primary", "-o"])
}
fn xclip_typed(sel: &str, mime: &str) -> Option<Vec<u8>> {
xclip(&["-selection", sel, "-t", mime, "-o"])
}
fn xclip_write(sel: &str, data: &[u8]) -> Option<Child> {
if !Path::new("/usr/bin/xclip").exists() {
return None;
}
let session = ensure_xvfb()?;
let mut child = Command::new("/usr/bin/xclip")
.args(["-selection", sel, "-i"])
.env("DISPLAY", &session.display)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(data).ok()?;
}
Some(child)
}
fn get_thread() -> Option<&'static X11Thread> {
ensure_xvfb()?;
match x11_thread() {
Ok(t) => Some(t),
Err(e) => {
eprintln!("SKIP: x11_thread init failed: {e}");
None
}
}
}
#[test]
fn xvfb_connection_and_atoms() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
if ensure_xvfb().is_none() {
return;
}
let conn = match super::super::x11::X11Connection::open() {
Ok(c) => c,
Err(ClipboardError::LibNotFound) => {
eprintln!("SKIP xvfb_connection_and_atoms: libxcb.so.1 not found");
return;
}
Err(e) => panic!("X11Connection::open failed: {e}"),
};
let screen = conn.screen();
assert_eq!(screen.width, 800, "screen width mismatch");
assert_eq!(screen.height, 600, "screen height mismatch");
assert_ne!(screen.root, 0, "root window must be non-zero");
assert_ne!(screen.root_visual, 0, "root visual must be non-zero");
assert!(
screen.max_request_len_bytes > 0,
"max_request_len_bytes must be > 0"
);
let a = conn.atoms();
for (val, name) in [
(a.clipboard, "CLIPBOARD"),
(a.primary, "PRIMARY"),
(a.targets, "TARGETS"),
(a.utf8_string, "UTF8_STRING"),
(a.string, "STRING"),
(a.text_plain_utf8, "text/plain;charset=utf-8"),
(a.text_html, "text/html"),
(a.text_rtf, "text/rtf"),
(a.text_uri_list, "text/uri-list"),
(a.image_png, "image/png"),
(a.incr, "INCR"),
(a.clipboard_manager, "CLIPBOARD_MANAGER"),
(a.save_targets, "SAVE_TARGETS"),
(a.multiple, "MULTIPLE"),
(a.hjkl_clipboard_get, "HJKL_CLIPBOARD_GET"),
] {
assert_ne!(val, 0, "atom {name} must be non-zero");
}
}
#[test]
fn set_clear_clipboard_text() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let text = b"hello-x11-5b";
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, text)
.expect("set_clipboard failed");
std::thread::sleep(Duration::from_millis(150));
let Some(out) = xclip_clipboard() else {
eprintln!("SKIP set_clear_clipboard_text: xclip not available");
return;
};
assert_eq!(out, text, "xclip clipboard read mismatch");
clear_clipboard(thread, Selection::Clipboard).expect("clear_clipboard failed");
std::thread::sleep(Duration::from_millis(150));
let after = xclip_clipboard().unwrap_or_default();
assert!(
after.is_empty(),
"expected empty after clear, got: {after:?}"
);
}
#[test]
fn set_primary_text() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let text = b"primary-selection-5b";
set_clipboard(thread, Selection::Primary, &MimeType::Text, text)
.expect("set primary failed");
std::thread::sleep(Duration::from_millis(150));
let Some(out) = xclip_primary() else {
eprintln!("SKIP set_primary_text: xclip not available");
return;
};
assert_eq!(out, text, "xclip primary read mismatch");
}
#[test]
fn set_html_payload() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let html = b"<b>hello</b>";
set_clipboard(thread, Selection::Clipboard, &MimeType::Html, html)
.expect("set html failed");
std::thread::sleep(Duration::from_millis(150));
let Some(out) = xclip_typed("clipboard", "text/html") else {
eprintln!("SKIP set_html_payload: xclip not available");
return;
};
assert_eq!(out, html, "xclip html read mismatch");
}
#[test]
fn set_replaces_previous() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, b"hello")
.expect("first set failed");
std::thread::sleep(Duration::from_millis(150));
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, b"world")
.expect("second set failed");
std::thread::sleep(Duration::from_millis(150));
let Some(out) = xclip_clipboard() else {
eprintln!("SKIP set_replaces_previous: xclip not available");
return;
};
assert_eq!(out, b"world", "expected 'world' after replace");
}
#[test]
fn get_clipboard_text() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let data = b"hello-get-5c\n";
let Some(mut child) = xclip_write("clipboard", data) else {
eprintln!("SKIP get_clipboard_text: xclip not available");
return;
};
std::thread::sleep(Duration::from_millis(150));
let result = get_clipboard(thread, Selection::Clipboard, &MimeType::Text);
let _ = child.wait();
let bytes = result.expect("get_clipboard failed");
assert_eq!(bytes, data, "get_clipboard text mismatch");
}
#[test]
fn get_primary_text() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let data = b"primary-get-5c\n";
let Some(mut child) = xclip_write("primary", data) else {
eprintln!("SKIP get_primary_text: xclip not available");
return;
};
std::thread::sleep(Duration::from_millis(150));
let result = get_clipboard(thread, Selection::Primary, &MimeType::Text);
let _ = child.wait();
let bytes = result.expect("get_clipboard (primary) failed");
assert_eq!(bytes, data, "get_clipboard primary text mismatch");
}
#[test]
fn get_after_self_set() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, b"loop").expect("set failed");
std::thread::sleep(Duration::from_millis(50));
let bytes =
get_clipboard(thread, Selection::Clipboard, &MimeType::Text).expect("self-read failed");
assert_eq!(bytes, b"loop", "self-read mismatch");
}
#[test]
fn available_lists_text() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let Some(mut child) = xclip_write("clipboard", b"available-test\n") else {
eprintln!("SKIP available_lists_text: xclip not available");
return;
};
std::thread::sleep(Duration::from_millis(150));
let result = available_clipboard(thread, Selection::Clipboard);
let _ = child.wait();
let mimes = result.expect("available_clipboard failed");
assert!(
mimes.contains(&MimeType::Text),
"expected Text in available mimes, got: {mimes:?}"
);
}
#[test]
fn get_unowned_returns_unsupported() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
clear_clipboard(thread, Selection::Clipboard).expect("clear failed");
std::thread::sleep(Duration::from_millis(100));
let err = get_clipboard(thread, Selection::Clipboard, &MimeType::Text)
.expect_err("expected error for unowned selection");
assert!(
matches!(err, ClipboardError::UnsupportedMime),
"expected UnsupportedMime, got: {err}"
);
}
#[test]
fn available_no_owner_returns_empty() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
clear_clipboard(thread, Selection::Clipboard).expect("clear failed");
std::thread::sleep(Duration::from_millis(100));
let mimes = available_clipboard(thread, Selection::Clipboard)
.expect("available_clipboard should return Ok");
assert!(
mimes.is_empty(),
"expected empty available list, got: {mimes:?}"
);
}
#[test]
fn large_payload_self_loop() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let size = 1024 * 1024;
let payload: Vec<u8> = (0u8..=255).cycle().take(size).collect();
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, &payload)
.expect("set large payload failed");
let received = get_clipboard(thread, Selection::Clipboard, &MimeType::Text)
.expect("get large payload failed");
assert_eq!(
received.len(),
size,
"large payload length mismatch: got {} expected {size}",
received.len()
);
assert_eq!(received, payload, "large payload content mismatch");
}
use std::collections::HashMap as TestHashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct MockManager {
handle: std::thread::JoinHandle<()>,
saw_save_targets: Arc<AtomicBool>,
received_payloads: Arc<Mutex<TestHashMap<u32, Vec<u8>>>>,
stop: Arc<AtomicBool>,
}
impl MockManager {
fn spawn(display: &str) -> Option<Self> {
let saw_save_targets = Arc::new(AtomicBool::new(false));
let received_payloads = Arc::new(Mutex::new(TestHashMap::new()));
let stop = Arc::new(AtomicBool::new(false));
let saw2 = Arc::clone(&saw_save_targets);
let payloads2 = Arc::clone(&received_payloads);
let stop2 = Arc::clone(&stop);
let display_str = display.to_string();
let _ = display_str;
let handle = std::thread::Builder::new()
.name("mock-clipboard-manager".into())
.spawn(move || {
let conn = match super::super::x11::X11Connection::open() {
Ok(c) => c,
Err(e) => {
eprintln!("MockManager: connection failed: {e}");
return;
}
};
mock_manager_run(conn, saw2, payloads2, stop2);
})
.ok()?;
Some(Self {
handle,
saw_save_targets,
received_payloads,
stop,
})
}
fn stop_and_join(self) {
self.stop.store(true, Ordering::SeqCst);
let _ = self.handle.join();
}
}
fn mock_manager_run(
conn: super::super::x11::X11Connection,
saw_save_targets: Arc<AtomicBool>,
received_payloads: Arc<Mutex<TestHashMap<u32, Vec<u8>>>>,
stop: Arc<AtomicBool>,
) {
use std::ffi::c_void;
let fns = conn.fns();
let raw = conn.raw();
let atoms = conn.atoms();
let wid = unsafe { (fns.xcb_generate_id)(raw) };
let screen = conn.screen();
let event_mask_mock: u32 = XCB_EVENT_MASK_PROPERTY_CHANGE;
unsafe {
(fns.xcb_create_window)(
raw,
screen.root_depth,
wid,
screen.root,
0,
0,
1,
1,
0,
XCB_WINDOW_CLASS_INPUT_OUTPUT,
screen.root_visual,
XCB_CW_EVENT_MASK,
std::ptr::addr_of!(event_mask_mock).cast::<c_void>(),
);
}
let mgr_property = atoms.hjkl_clipboard_get;
unsafe {
(fns.xcb_set_selection_owner)(raw, wid, atoms.clipboard_manager, XCB_CURRENT_TIME);
(fns.xcb_flush)(raw);
}
loop {
if stop.load(Ordering::SeqCst) {
break;
}
let ev = unsafe { (fns.xcb_poll_for_event)(raw) };
if ev.is_null() {
std::thread::sleep(Duration::from_millis(10));
continue;
}
let response_type = unsafe { *(ev as *const u8) } & 0x7f;
match response_type {
XCB_SELECTION_REQUEST => {
let req = unsafe { *(ev as *const SelectionRequestEvent) };
if req.target == atoms.save_targets {
saw_save_targets.store(true, Ordering::SeqCst);
let prop = if req.property == XCB_NONE {
req.target
} else {
req.property
};
let targets = mock_read_property(fns, raw, req.requestor, prop);
let atom_list: Vec<u32> = targets
.chunks_exact(4)
.map(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
.collect();
for target in atom_list {
if target == atoms.targets || target == atoms.multiple {
continue; }
unsafe {
(fns.xcb_delete_property)(raw, wid, mgr_property);
(fns.xcb_convert_selection)(
raw,
wid,
atoms.clipboard,
target,
mgr_property,
XCB_CURRENT_TIME,
);
(fns.xcb_flush)(raw);
}
let deadline = Instant::now() + Duration::from_secs(5);
'wait: loop {
let ev2 = unsafe { (fns.xcb_poll_for_event)(raw) };
if !ev2.is_null() {
let rt2 = unsafe { *(ev2 as *const u8) } & 0x7f;
if rt2 == XCB_SELECTION_NOTIFY {
let sn = unsafe { *(ev2 as *const SelectionNotifyEvent) };
if sn.requestor == wid {
if sn.property != XCB_NONE {
let data = mock_read_full(
fns,
raw,
wid,
mgr_property,
atoms.incr,
);
received_payloads
.lock()
.unwrap()
.insert(target, data);
}
unsafe { libc::free(ev2.cast()) };
break 'wait;
}
}
unsafe { libc::free(ev2.cast()) };
}
if Instant::now() >= deadline {
break 'wait;
}
std::thread::sleep(Duration::from_millis(5));
}
}
let mut buf = [0u8; 32];
buf[0] = XCB_SELECTION_NOTIFY;
buf[4..8].copy_from_slice(&req.time.to_ne_bytes());
buf[8..12].copy_from_slice(&req.requestor.to_ne_bytes());
buf[12..16].copy_from_slice(&req.selection.to_ne_bytes());
buf[16..20].copy_from_slice(&req.target.to_ne_bytes());
let reply_prop = if req.property == XCB_NONE {
XCB_NONE
} else {
req.property
};
buf[20..24].copy_from_slice(&reply_prop.to_ne_bytes());
unsafe {
(fns.xcb_send_event)(raw, 0, req.requestor, 0, buf.as_ptr().cast());
(fns.xcb_flush)(raw);
}
}
}
XCB_SELECTION_CLEAR => {
break;
}
_ => {}
}
unsafe { libc::free(ev.cast()) };
}
}
fn mock_read_property(
fns: &super::super::dlopen::XcbFns,
raw: *mut super::super::dlopen::XcbConnection,
w: u32,
prop: u32,
) -> Vec<u8> {
let cookie = unsafe {
(fns.xcb_get_property)(raw, 0, w, prop, XCB_GET_PROPERTY_TYPE_ANY, 0, u32::MAX / 4)
};
let reply = unsafe { (fns.xcb_get_property_reply)(raw, cookie, std::ptr::null_mut()) };
if reply.is_null() {
return Vec::new();
}
let vptr = unsafe { (fns.xcb_get_property_value)(reply) };
let vlen = unsafe { (fns.xcb_get_property_value_length)(reply) } as usize;
let bytes = if vlen > 0 && !vptr.is_null() {
unsafe { std::slice::from_raw_parts(vptr as *const u8, vlen).to_vec() }
} else {
Vec::new()
};
unsafe { libc::free(reply.cast()) };
bytes
}
fn mock_read_full(
fns: &super::super::dlopen::XcbFns,
raw: *mut super::super::dlopen::XcbConnection,
wid: u32,
prop: u32,
incr_atom: u32,
) -> Vec<u8> {
let cookie = unsafe {
(fns.xcb_get_property)(
raw,
1,
wid,
prop,
XCB_GET_PROPERTY_TYPE_ANY,
0,
u32::MAX / 4,
)
};
let reply = unsafe { (fns.xcb_get_property_reply)(raw, cookie, std::ptr::null_mut()) };
if reply.is_null() {
return Vec::new();
}
let type_atom = unsafe { (*reply).r#type };
let vptr = unsafe { (fns.xcb_get_property_value)(reply) };
let vlen = unsafe { (fns.xcb_get_property_value_length)(reply) } as usize;
let initial = if vlen > 0 && !vptr.is_null() {
unsafe { std::slice::from_raw_parts(vptr as *const u8, vlen).to_vec() }
} else {
Vec::new()
};
unsafe { libc::free(reply.cast()) };
if type_atom != incr_atom {
return initial;
}
unsafe {
(fns.xcb_delete_property)(raw, wid, prop);
(fns.xcb_flush)(raw);
}
let mut acc = Vec::new();
let total_deadline = Instant::now() + Duration::from_secs(30);
loop {
let chunk_deadline = Instant::now() + Duration::from_secs(10);
let got_notify = 'notify: loop {
let ev = unsafe { (fns.xcb_poll_for_event)(raw) };
if !ev.is_null() {
let rt = unsafe { *(ev as *const u8) } & 0x7f;
if rt == XCB_PROPERTY_NOTIFY {
let pn = unsafe { *(ev as *const PropertyNotifyEvent) };
if pn.window == wid && pn.atom == prop && pn.state == XCB_PROPERTY_NEW_VALUE
{
unsafe { libc::free(ev.cast()) };
break 'notify true;
}
}
unsafe { libc::free(ev.cast()) };
}
if Instant::now() >= chunk_deadline || Instant::now() >= total_deadline {
break 'notify false;
}
std::thread::sleep(Duration::from_millis(5));
};
if !got_notify {
break;
}
let cookie2 = unsafe {
(fns.xcb_get_property)(
raw,
1,
wid,
prop,
XCB_GET_PROPERTY_TYPE_ANY,
0,
u32::MAX / 4,
)
};
let r2 = unsafe { (fns.xcb_get_property_reply)(raw, cookie2, std::ptr::null_mut()) };
if r2.is_null() {
break;
}
let vp2 = unsafe { (fns.xcb_get_property_value)(r2) };
let vl2 = unsafe { (fns.xcb_get_property_value_length)(r2) } as usize;
let chunk = if vl2 > 0 && !vp2.is_null() {
unsafe { std::slice::from_raw_parts(vp2 as *const u8, vl2).to_vec() }
} else {
Vec::new()
};
unsafe { libc::free(r2.cast()) };
if chunk.is_empty() {
break; }
acc.extend_from_slice(&chunk);
if Instant::now() >= total_deadline {
break;
}
}
acc
}
#[test]
fn save_targets_invokes_manager() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let Some(session) = ensure_xvfb() else { return };
let Some(mgr) = MockManager::spawn(&session.display) else {
eprintln!("SKIP save_targets_invokes_manager: MockManager spawn failed");
return;
};
std::thread::sleep(Duration::from_millis(100));
let text = b"save-targets-test-payload";
set_clipboard(thread, Selection::Clipboard, &MimeType::Text, text).expect("set failed");
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if mgr.saw_save_targets.load(Ordering::SeqCst) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
mgr.saw_save_targets.load(Ordering::SeqCst),
"MockManager never saw SAVE_TARGETS request"
);
std::thread::sleep(Duration::from_millis(500));
let payloads = mgr.received_payloads.lock().unwrap();
let utf8_atom = thread.atoms.utf8_string;
assert!(
payloads.contains_key(&utf8_atom),
"MockManager did not receive UTF8_STRING payload; keys: {:?}",
payloads.keys().collect::<Vec<_>>()
);
let received = payloads.get(&utf8_atom).unwrap();
assert_eq!(received, text, "MockManager received wrong payload bytes");
drop(payloads);
mgr.stop_and_join();
}
#[test]
fn save_targets_no_manager() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let before = Instant::now();
set_clipboard(
thread,
Selection::Clipboard,
&MimeType::Text,
b"no-manager-test",
)
.expect("set should succeed even with no manager");
let elapsed = before.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"set took too long ({elapsed:?}); possible hang in SAVE_TARGETS"
);
}
#[test]
fn x11_custom_mime_set_round_trip() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let mime = MimeType::Custom("application/x-hjkl-test".into());
let data = b"custom-mime-payload-7a";
set_clipboard(thread, Selection::Clipboard, &mime, data).expect("set custom mime failed");
std::thread::sleep(Duration::from_millis(150));
let Some(out) = xclip_typed("clipboard", "application/x-hjkl-test") else {
eprintln!("SKIP x11_custom_mime_set_round_trip: xclip not available");
return;
};
assert_eq!(out, data, "custom mime xclip read mismatch");
}
#[test]
fn x11_custom_mime_get_round_trip() {
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(thread) = get_thread() else { return };
let mime = MimeType::Custom("application/x-hjkl-get-test".into());
let data = b"get-custom-payload-7a";
set_clipboard(thread, Selection::Clipboard, &mime, data).expect("set custom mime failed");
std::thread::sleep(Duration::from_millis(50));
let received =
get_clipboard(thread, Selection::Clipboard, &mime).expect("get custom mime failed");
assert_eq!(received, data, "custom mime self-loop mismatch");
}
#[test]
fn error_type_preserved_across_calls() {
let r1 = x11_thread();
let r2 = x11_thread();
match (r1, r2) {
(Ok(_), Ok(_)) => {}
(Err(ClipboardError::LibNotFound), Err(ClipboardError::LibNotFound)) => {}
(Err(ClipboardError::NoDisplay), Err(ClipboardError::NoDisplay)) => {}
(Err(ClipboardError::Io(_)), Err(ClipboardError::Io(_))) => {}
(a, b) => panic!(
"error variant changed between calls: first={a:?} second={b:?}",
a = a.err().map(|e| e.to_string()),
b = b.err().map(|e| e.to_string()),
),
}
}
}