atagen

monolith

public
Star0
Watch0

Commit 8c987154f02d

tarball

atagen <boss@atagen.co>2026-10-19 07:56 UTC

2 files changed+82-1
xwayland: serve STRING to X11 requestors as Latin-1

ICCCM defines STRING as ISO-8859-1 and old X11 clients ask for nothing else, but smithay's xwm only maps text/plain to UTF8_STRING and TEXT. Advertising STRING for text selections and relaying the Wayland source's UTF-8 through a Latin-1 transcode means those clients get readable text instead of raw UTF-8 bytes or a refusal.
Modified file: crates/monolith/src/seat.rs+1 additions−1 deletions
@@ -53,7 +53,7 @@ impl SelectionHandler for State {
5353 };
5454 *x.wayland_selection(ty) = mimes.clone();
5555 if let Some(wm) = x.wm.as_mut()
56- && let Err(e) = wm.new_selection(ty, mimes)
56+ && let Err(e) = wm.new_selection(ty, mimes.map(crate::xwayland::with_string_target))
5757 {
5858 tracing::warn!("cannot set the X11 {ty:?} selection: {e:?}");
5959 }
Modified file: crates/monolith/src/xwayland.rs+81 additions
@@ -9,6 +9,7 @@ use monolith_config::SelectionAccess;
99 use smithay::desktop::Window;
1010 use smithay::reexports::calloop::generic::Generic;
1111 use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken};
12+use smithay::reexports::rustix::fs::{OFlags, fcntl_setfl};
1213 use smithay::reexports::rustix::io::{FdFlags, fcntl_getfd, fcntl_setfd};
1314 use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
1415 use smithay::utils::{Logical, Rectangle, Size};
@@ -24,12 +25,54 @@ use smithay::wayland::selection::primary_selection::{
2425 use smithay::wayland::xwayland_shell::{XWaylandShellHandler, XWaylandShellState};
2526 use smithay::xwayland::xwm::{Reorder, ResizeEdge, WmWindowProperty, WmWindowType, XwmId};
2627 use smithay::xwayland::{X11Surface, X11Wm, XWayland, XWaylandEvent, XwmHandler};
28+use std::io::{Read, Write};
2729 use std::os::fd::AsFd;
2830 use std::os::fd::OwnedFd;
2931 use std::os::linux::net::SocketAddrExt;
3032 use std::os::unix::net::{SocketAddr, UnixListener};
3133 use std::process::Stdio;
3234
35+const UTF8_TEXT: [&str; 4] = [
36+ "text/plain;charset=utf-8",
37+ "text/plain;charset=UTF-8",
38+ "UTF8_STRING",
39+ "text/plain",
40+];
41+
42+fn utf8_source(mimes: &[String]) -> Option<&str> {
43+ UTF8_TEXT.into_iter().find(|t| mimes.iter().any(|m| m == t))
44+}
45+
46+/// X11 requestors that predate UTF8_STRING only ask for STRING, which smithay
47+/// leaves out of the targets it offers for a Wayland-owned text selection.
48+pub fn with_string_target(mut mimes: Vec<String>) -> Vec<String> {
49+ if utf8_source(&mimes).is_some() && !mimes.iter().any(|m| m == "STRING") {
50+ mimes.push("STRING".into());
51+ }
52+ mimes
53+}
54+
55+fn latin1(text: &[u8]) -> Vec<u8> {
56+ String::from_utf8_lossy(text)
57+ .chars()
58+ .map(|c| u8::try_from(u32::from(c)).unwrap_or(b'?'))
59+ .collect()
60+}
61+
62+/// Hands back the write end of a pipe whose UTF-8 contents reach `out` as
63+/// Latin-1, which is what ICCCM says STRING holds.
64+fn latin1_relay(out: OwnedFd) -> std::io::Result<OwnedFd> {
65+ let (mut read, write) = std::io::pipe()?;
66+ fcntl_setfl(&out, OFlags::empty())?;
67+ std::thread::spawn(move || {
68+ let mut text = Vec::new();
69+ if read.read_to_end(&mut text).is_ok() {
70+ let _ = std::fs::File::from(out).write_all(&latin1(&text));
71+ }
72+ });
73+ Ok(write.into())
74+}
75+
3376 pub struct Xwayland {
3477 pub display: u32,
3578 listeners: Vec<UnixListener>,
@@ -222,6 +265,7 @@ fn start(state: &mut State, fired: usize) {
222265 if let Some(x) = state.services.xwayland.as_mut() {
223266 for ty in [SelectionTarget::Clipboard, SelectionTarget::Primary] {
224267 if let Some(mimes) = x.wayland_selection(ty).clone() {
268+ let mimes = with_string_target(mimes);
225269 logged("selection", wm.new_selection(ty, Some(mimes)));
226270 }
227271 }
@@ -372,6 +416,23 @@ impl XwmHandler for State {
372416 mime_type: String,
373417 fd: OwnedFd,
374418 ) {
419+ let (mime_type, fd) = if mime_type == "STRING" {
420+ let source = self.services.xwayland.as_mut().and_then(|x| {
421+ let mimes = x.wayland_selection(selection).as_deref()?;
422+ utf8_source(mimes).map(str::to_owned)
423+ });
424+ let relay = source.map(|s| latin1_relay(fd).map(|w| (s, w)));
425+ match relay {
426+ Some(Ok(relay)) => relay,
427+ Some(Err(e)) => {
428+ tracing::warn!("cannot relay {selection:?} to X11 as STRING: {e}");
429+ return;
430+ }
431+ None => return,
432+ }
433+ } else {
434+ (mime_type, fd)
435+ };
375436 let seat = &self.input.seat;
376437 let result = match selection {
377438 SelectionTarget::Clipboard => request_data_device_client_selection(seat, mime_type, fd)
@@ -502,3 +563,23 @@ fn holds_display(pid: u32) -> bool {
502563 std::fs::read_to_string(format!("/proc/{pid}/comm"))
503564 .is_ok_and(|comm| comm.starts_with('X') || comm.trim() == "monolith")
504565 }
566+
567+#[cfg(test)]
568+mod tests {
569+ use super::*;
570+
571+ #[test]
572+ fn latin1_keeps_the_range_and_replaces_the_rest() {
573+ assert_eq!(latin1("café ÿ".as_bytes()), b"caf\xe9 \xff");
574+ assert_eq!(latin1("a€😀".as_bytes()), b"a??");
575+ assert_eq!(latin1(b"a\xc3"), b"a?");
576+ }
577+
578+ #[test]
579+ fn string_is_offered_for_text_only() {
580+ let text = vec!["text/plain;charset=utf-8".to_string()];
581+ assert_eq!(with_string_target(text).last().unwrap(), "STRING");
582+ let image = vec!["image/png".to_string()];
583+ assert_eq!(with_string_target(image.clone()), image);
584+ }
585+}