Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ errno = "0.3"
# `XKeysymToString` tier of `modify_event_symbol`, and this is that table
# without an X11 or xkbcommon dependency, so it works on every platform.
keysymdefs = "0.2"
rustix = { version = "1", features = ["fs"] }
rustix = { version = "1", features = ["event", "fs"] }
inotify = "0.11"
fs4 = { version = "1", features = ["sync"] }
sysinfo = "0.39"
Expand Down
113 changes: 94 additions & 19 deletions crates/neomacs-display-runtime/src/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ use arboard::{ClearExtLinux, GetExtLinux, LinuxClipboardKind, SetExtLinux};
#[cfg(target_os = "linux")]
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};

#[cfg(target_os = "linux")]
mod wayland_data_control;

const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250);
const REQUEST_QUEUE_CAPACITY: usize = 32;

Expand Down Expand Up @@ -55,10 +58,7 @@ impl ClipboardService {
let clipboard = unsafe {
smithay_clipboard::Clipboard::new(raw_display.display.as_ptr())
};
return Self::start(WaylandClipboard {
clipboard,
_display_owner: display,
});
return Self::start(WaylandClipboard::new(clipboard, display));
}
}
_ => {
Expand Down Expand Up @@ -372,11 +372,91 @@ impl ClipboardBackend for ArboardClipboard {

#[cfg(target_os = "linux")]
struct WaylandClipboard {
// Field order is significant: drop the protocol worker before its display.
// Field order is significant: drop the protocol objects before their display.
clipboard: smithay_clipboard::Clipboard,
data_control: DataControl,
_display_owner: OwnedDisplayHandle,
}

/// Lazily bound fallback for CLIPBOARD reads; see `wayland_data_control`.
#[cfg(target_os = "linux")]
enum DataControl {
Unbound,
Unavailable,
Bound(wayland_data_control::DataControlReader),
}

#[cfg(target_os = "linux")]
impl WaylandClipboard {
fn new(clipboard: smithay_clipboard::Clipboard, display: OwnedDisplayHandle) -> Self {
Self {
clipboard,
data_control: DataControl::Unbound,
_display_owner: display,
}
}

fn data_control_text(&mut self) -> Result<Option<String>, String> {
if let DataControl::Unbound = self.data_control {
self.data_control = match self.bind_data_control() {
Ok(Some(reader)) => DataControl::Bound(reader),
Ok(None) => DataControl::Unavailable,
Err(err) => {
tracing::warn!("clipboard data-control fallback unavailable: {err}");
DataControl::Unavailable
}
};
}
match &mut self.data_control {
DataControl::Bound(reader) => reader.read_text(),
DataControl::Unbound | DataControl::Unavailable => Ok(None),
}
}

fn bind_data_control(&self) -> Result<Option<wayland_data_control::DataControlReader>, String> {
let RawDisplayHandle::Wayland(raw_display) = self
._display_owner
.display_handle()
.map_err(|err| format!("failed to access the native display: {err}"))?
.as_raw()
else {
return Ok(None);
};
// SAFETY: `_display_owner` keeps the display alive and is dropped
// after `data_control` (field order above).
unsafe { wayland_data_control::DataControlReader::connect(raw_display.display) }
}
}

/// Whether smithay-clipboard saw no usable selection offer.
///
/// smithay-clipboard reports a missing offer as the untyped error
/// `selection is empty` and a missing text type as `NotFound`; both mean
/// "nothing to paste" rather than failure. Its other errors (no seat, no
/// keyboard focus) are real failures.
#[cfg(target_os = "linux")]
fn smithay_saw_no_offer(err: &std::io::Error) -> bool {
err.kind() == std::io::ErrorKind::NotFound || err.to_string() == "selection is empty"
}

/// Map a smithay-clipboard read result, consulting `fallback` only when it
/// saw no usable selection offer. A failed or timed-out fallback reads as
/// an empty selection (nil), not an error.
#[cfg(target_os = "linux")]
fn smithay_text_or_fallback(
result: std::io::Result<String>,
fallback: impl FnOnce() -> Result<Option<String>, String>,
) -> Result<Option<String>, String> {
match result {
Ok(text) => Ok(Some(text)),
Err(err) if smithay_saw_no_offer(&err) => fallback().or_else(|err| {
tracing::warn!("clipboard data-control read failed: {err}");
Ok(None)
}),
Err(err) => Err(err.to_string()),
}
}

#[cfg(target_os = "linux")]
impl ClipboardBackend for WaylandClipboard {
fn set_text(
Expand All @@ -400,21 +480,16 @@ impl ClipboardBackend for WaylandClipboard {
}

fn text(&mut self, selection: ClipboardSelection) -> Result<Option<String>, String> {
let result = match selection {
ClipboardSelection::Clipboard => self.clipboard.load(),
ClipboardSelection::Primary => self.clipboard.load_primary(),
};
match result {
Ok(text) => Ok(Some(text)),
// smithay-clipboard currently exposes an untyped io::Error for an
// unowned selection, including this stable error message.
Err(err)
if err.kind() == std::io::ErrorKind::NotFound
|| err.to_string() == "selection is empty" =>
{
Ok(None)
match selection {
ClipboardSelection::Clipboard => {
let result = self.clipboard.load();
smithay_text_or_fallback(result, || self.data_control_text())
}
// smithay-clipboard owns the only primary-selection device, so
// its result is complete.
ClipboardSelection::Primary => {
smithay_text_or_fallback(self.clipboard.load_primary(), || Ok(None))
}
Err(err) => Err(err.to_string()),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,74 @@ fn arboard_backend_keeps_primary_in_process_local_state() {
.expect("PRIMARY disown must not fail on this platform");
assert_eq!(backend.text(ClipboardSelection::Primary), Ok(None));
}

#[cfg(target_os = "linux")]
mod wayland_fallback {
use super::super::smithay_text_or_fallback;
use std::cell::Cell;
use std::io;

fn no_offer() -> io::Result<String> {
Err(io::Error::other("selection is empty"))
}

#[test]
fn smithay_text_is_returned_without_consulting_data_control() {
let consulted = Cell::new(false);
let result = smithay_text_or_fallback(Ok("native".to_owned()), || {
consulted.set(true);
Ok(Some("data-control".to_owned()))
});
assert_eq!(result, Ok(Some("native".to_owned())));
assert!(!consulted.get());
}

#[test]
fn missing_selection_offer_reads_through_data_control() {
// Hyprland delivers the selection only to winit's data device, so
// smithay-clipboard's device never holds an offer.
let result = smithay_text_or_fallback(no_offer(), || Ok(Some("foreign".to_owned())));
assert_eq!(result, Ok(Some("foreign".to_owned())));
}

#[test]
fn missing_text_mime_reads_through_data_control() {
let result = smithay_text_or_fallback(
Err(io::Error::new(
io::ErrorKind::NotFound,
"supported mime-type is not found",
)),
|| Ok(Some("text from data-control".to_owned())),
);
assert_eq!(result, Ok(Some("text from data-control".to_owned())));
}

#[test]
fn empty_transfer_is_text_not_a_missing_selection() {
let result = smithay_text_or_fallback(Ok(String::new()), || {
panic!("an empty transfer must not consult data-control")
});
assert_eq!(result, Ok(Some(String::new())));
}

#[test]
fn seat_and_focus_errors_are_not_masked_by_data_control() {
for message in [
"client doesn't have focus",
"no events received on any seat",
] {
let result = smithay_text_or_fallback(Err(io::Error::other(message)), || {
panic!("{message} must not consult data-control")
});
assert_eq!(result, Err(message.to_owned()));
}
}

#[test]
fn failed_data_control_read_keeps_the_empty_result() {
let result = smithay_text_or_fallback(no_offer(), || {
Err("data-control transfer timed out".to_owned())
});
assert_eq!(result, Ok(None));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use super::*;

fn offered(mimes: &[&str]) -> Vec<String> {
mimes.iter().map(|mime| (*mime).to_owned()).collect()
}

#[test]
fn utf8_types_win_in_offer_order_and_plain_text_is_a_fallback() {
assert_eq!(
TextMime::choose(&offered(&[
"text/plain",
"UTF8_STRING",
"text/plain;charset=utf-8"
])),
Some(TextMime::Utf8String)
);
assert_eq!(
TextMime::choose(&offered(&["TEXT", "text/plain;charset=utf-8"])),
Some(TextMime::TextPlainUtf8)
);
assert_eq!(
TextMime::choose(&offered(&["image/png", "text/plain"])),
Some(TextMime::TextPlain)
);
}

#[test]
fn offers_without_a_text_type_have_no_text() {
assert_eq!(TextMime::choose(&offered(&[])), None);
assert_eq!(
TextMime::choose(&offered(&[
"image/png",
"text/plain;charset=iso-8859-1",
"STRING"
])),
None
);
}

#[test]
fn plain_text_line_ends_are_normalized_like_smithay_clipboard() {
let bytes = b"a\r\nb\rc\n";
assert_eq!(TextMime::TextPlainUtf8.decode(bytes), "a\nb\nc\n");
assert_eq!(TextMime::TextPlain.decode(bytes), "a\nb\nc\n");
assert_eq!(TextMime::Utf8String.decode(bytes), "a\r\nb\rc\n");
}

#[test]
fn invalid_utf8_is_decoded_lossily() {
assert_eq!(TextMime::Utf8String.decode(b"ok\xff"), "ok\u{fffd}");
}

#[test]
fn transfer_reads_until_the_owner_closes_its_end() {
use std::io::Write;
let (mut reader, mut writer) = std::io::pipe().unwrap();
let owner = std::thread::spawn(move || {
writer.write_all(b"first ").unwrap();
std::thread::sleep(Duration::from_millis(20));
writer.write_all(b"second").unwrap();
});
let bytes = read_to_end_before(&mut reader, Instant::now() + Duration::from_secs(5)).unwrap();
owner.join().unwrap();
assert_eq!(bytes, b"first second");
}

#[test]
fn stalled_selection_owner_times_out() {
let (mut reader, writer) = std::io::pipe().unwrap();
let started = Instant::now();
let result = read_to_end_before(&mut reader, started + Duration::from_millis(50));
assert_eq!(result, Err("data-control transfer timed out".to_owned()));
assert!(started.elapsed() < Duration::from_secs(2));
drop(writer);
}
Loading
Loading