Implement xwayland-satellite integration

This commit is contained in:
Ivan Molodetskikh 2025-06-04 08:26:51 +03:00
parent 0698f167e5
commit f918eabe6a
9 changed files with 579 additions and 4 deletions

View file

@ -65,6 +65,8 @@ pub struct Config {
pub overview: Overview,
#[knuffel(child, default)]
pub environment: Environment,
#[knuffel(child, default)]
pub xwayland_satellite: XwaylandSatellite,
#[knuffel(children(name = "window-rule"))]
pub window_rules: Vec<WindowRule>,
#[knuffel(children(name = "layer-rule"))]
@ -1364,6 +1366,23 @@ pub struct EnvironmentVariable {
pub value: Option<String>,
}
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)]
pub struct XwaylandSatellite {
#[knuffel(child)]
pub off: bool,
#[knuffel(child, unwrap(argument), default = Self::default().path)]
pub path: String,
}
impl Default for XwaylandSatellite {
fn default() -> Self {
Self {
off: false,
path: String::from("xwayland-satellite"),
}
}
}
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)]
pub struct Workspace {
#[knuffel(argument)]
@ -4796,6 +4815,10 @@ mod tests {
},
],
),
xwayland_satellite: XwaylandSatellite {
off: false,
path: "xwayland-satellite",
},
window_rules: [
WindowRule {
matches: [

View file

@ -17,11 +17,11 @@ use niri::dbus;
use niri::ipc::client::handle_msg;
use niri::niri::State;
use niri::utils::spawning::{
spawn, store_and_increase_nofile_rlimit, CHILD_ENV, REMOVE_ENV_RUST_BACKTRACE,
spawn, store_and_increase_nofile_rlimit, CHILD_DISPLAY, CHILD_ENV, REMOVE_ENV_RUST_BACKTRACE,
REMOVE_ENV_RUST_LIB_BACKTRACE,
};
use niri::utils::watcher::Watcher;
use niri::utils::{cause_panic, version, IS_SYSTEMD_SERVICE};
use niri::utils::{cause_panic, version, xwayland, IS_SYSTEMD_SERVICE};
use niri_config::Config;
use niri_ipc::socket::SOCKET_PATH_ENV;
use portable_atomic::Ordering;
@ -200,6 +200,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
info!("IPC listening on: {}", socket_path.to_string_lossy());
}
// Setup xwayland-satellite integration.
xwayland::satellite::setup(&mut state);
if let Some(satellite) = &state.niri.satellite {
let name = satellite.display_name();
*CHILD_DISPLAY.write().unwrap() = Some(name.to_owned());
env::set_var("DISPLAY", name);
info!("listening on X11 socket: {name}");
} else {
// Avoid spawning children in the host X11.
env::remove_var("DISPLAY");
}
if cli.session {
// We're starting as a session. Import our variables.
import_environment();
@ -275,6 +287,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
fn import_environment() {
let variables = [
"WAYLAND_DISPLAY",
"DISPLAY",
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_TYPE",
SOCKET_PATH_ENV,

View file

@ -160,11 +160,12 @@ use crate::ui::hotkey_overlay::HotkeyOverlay;
use crate::ui::screen_transition::{self, ScreenTransition};
use crate::ui::screenshot_ui::{OutputScreenshot, ScreenshotUi, ScreenshotUiRenderElement};
use crate::utils::scale::{closest_representable_scale, guess_monitor_scale};
use crate::utils::spawning::CHILD_ENV;
use crate::utils::spawning::{CHILD_DISPLAY, CHILD_ENV};
use crate::utils::xwayland::satellite::Satellite;
use crate::utils::{
center, center_f64, expand_home, get_monotonic_time, ipc_transform_to_smithay, is_mapped,
logical_output, make_screenshot_path, output_matches_name, output_size, send_scale_transform,
write_png_rgba8,
write_png_rgba8, xwayland,
};
use crate::window::mapped::MappedId;
use crate::window::{InitialConfigureState, Mapped, ResolvedWindowRules, Unmapped, WindowRef};
@ -376,6 +377,8 @@ pub struct Niri {
pub ipc_server: Option<IpcServer>,
pub ipc_outputs_changed: bool,
pub satellite: Option<Satellite>,
// Casts are dropped before PipeWire to prevent a double-free (yay).
pub casts: Vec<Cast>,
pub pipewire: Option<PipeWire>,
@ -1332,6 +1335,7 @@ impl State {
let mut layer_rules_changed = false;
let mut shaders_changed = false;
let mut cursor_inactivity_timeout_changed = false;
let mut xwls_changed = false;
let mut old_config = self.niri.config.borrow_mut();
// Reload the cursor.
@ -1443,6 +1447,10 @@ impl State {
output_config_changed = true;
}
if config.xwayland_satellite != old_config.xwayland_satellite {
xwls_changed = true;
}
*old_config = config;
if let Some(outputs) = preserved_output_config {
@ -1506,6 +1514,30 @@ impl State {
self.niri.reset_pointer_inactivity_timer();
}
if xwls_changed {
// If xwl-s was previously working and is now off, we don't try to kill it or stop
// watching the sockets, for simplicity's sake.
let was_working = self.niri.satellite.is_some();
// Try to start, or restart in case the user corrected the path or something.
xwayland::satellite::setup(self);
let config = self.niri.config.borrow();
let display_name = (!config.xwayland_satellite.off)
.then_some(self.niri.satellite.as_ref())
.flatten()
.map(|satellite| satellite.display_name().to_owned());
if let Some(name) = &display_name {
if !was_working {
info!("listening on X11 socket: {name}");
}
}
// This won't change the systemd environment, but oh well.
*CHILD_DISPLAY.write().unwrap() = display_name;
}
// Can't really update xdg-decoration settings since we have to hide the globals for CSD
// due to the SDL2 bug... I don't imagine clients are prepared for the xdg-decoration
// global suddenly appearing? Either way, right now it's live-reloaded in a sense that new
@ -2569,6 +2601,8 @@ impl Niri {
ipc_server,
ipc_outputs_changed: false,
satellite: None,
pipewire: None,
casts: vec![],
#[cfg(feature = "xdp-gnome-screencast")]

View file

@ -37,6 +37,7 @@ pub mod scale;
pub mod spawning;
pub mod transaction;
pub mod watcher;
pub mod xwayland;
pub static IS_SYSTEMD_SERVICE: AtomicBool = AtomicBool::new(false);

View file

@ -16,6 +16,7 @@ use crate::utils::expand_home;
pub static REMOVE_ENV_RUST_BACKTRACE: AtomicBool = AtomicBool::new(false);
pub static REMOVE_ENV_RUST_LIB_BACKTRACE: AtomicBool = AtomicBool::new(false);
pub static CHILD_ENV: RwLock<Environment> = RwLock::new(Environment(Vec::new()));
pub static CHILD_DISPLAY: RwLock<Option<String>> = RwLock::new(None);
static ORIGINAL_NOFILE_RLIMIT_CUR: Atomic<rlim_t> = Atomic::new(0);
static ORIGINAL_NOFILE_RLIMIT_MAX: Atomic<rlim_t> = Atomic::new(0);
@ -116,6 +117,14 @@ fn spawn_sync(
process.env_remove("RUST_LIB_BACKTRACE");
}
// Set DISPLAY if needed.
let display = CHILD_DISPLAY.read().unwrap();
if let Some(display) = &*display {
process.env("DISPLAY", display);
} else {
process.env_remove("DISPLAY");
}
// Set configured environment.
let env = CHILD_ENV.read().unwrap();
for var in &env.0 {

151
src/utils/xwayland/mod.rs Normal file
View file

@ -0,0 +1,151 @@
use std::os::fd::OwnedFd;
use std::os::linux::net::SocketAddrExt;
use std::os::unix::net::{SocketAddr, UnixListener};
use anyhow::{anyhow, ensure, Context as _};
use rustix::fs::{lstat, mkdir};
use rustix::io::Errno;
use rustix::process::getuid;
use smithay::reexports::rustix::fs::{unlink, OFlags};
use smithay::reexports::rustix::process::getpid;
use smithay::reexports::rustix::{self};
pub mod satellite;
const TMP_UNIX_DIR: &str = "/tmp";
const X11_TMP_UNIX_DIR: &str = "/tmp/.X11-unix";
struct X11Connection {
display_name: String,
abstract_fd: OwnedFd,
unix_fd: OwnedFd,
_unix_guard: Unlink,
_lock_guard: Unlink,
}
struct Unlink(String);
impl Drop for Unlink {
fn drop(&mut self) {
let _ = unlink(&self.0);
}
}
// Adapted from Mutter code:
// https://gitlab.gnome.org/GNOME/mutter/-/blob/48.3.1/src/wayland/meta-xwayland.c?ref_type=tags#L513
fn ensure_x11_unix_dir() -> anyhow::Result<()> {
match mkdir(X11_TMP_UNIX_DIR, 0o1777.into()) {
Ok(()) => Ok(()),
Err(Errno::EXIST) => {
ensure_x11_unix_perms().context("wrong X11 directory permissions")?;
Ok(())
}
Err(err) => Err(err).context("error creating X11 directory"),
}
}
fn ensure_x11_unix_perms() -> anyhow::Result<()> {
let x11_tmp = lstat(X11_TMP_UNIX_DIR).context("error checking X11 directory permissions")?;
let tmp = lstat(TMP_UNIX_DIR).context("error checking /tmp directory permissions")?;
ensure!(
x11_tmp.st_uid == tmp.st_uid || x11_tmp.st_uid == getuid().as_raw(),
"wrong ownership for X11 directory"
);
ensure!(
(x11_tmp.st_mode & 0o022) == 0o022,
"X11 directory is not writable"
);
ensure!(
(x11_tmp.st_mode & 0o1000) == 0o1000,
"X11 directory is missing the sticky bit"
);
Ok(())
}
fn pick_x11_display(start: u32) -> anyhow::Result<(u32, OwnedFd, Unlink)> {
for n in start..start + 50 {
let lock_path = format!("/tmp/.X{n}-lock");
let flags = OFlags::WRONLY | OFlags::CLOEXEC | OFlags::CREATE | OFlags::EXCL;
let Ok(lock_fd) = rustix::fs::open(&lock_path, flags, 0o444.into()) else {
// FIXME: check if the target process is dead and reuse the lock.
continue;
};
return Ok((n, lock_fd, Unlink(lock_path)));
}
Err(anyhow!("no free X11 display found after 50 attempts"))
}
fn bind_to_socket(addr: &SocketAddr) -> anyhow::Result<UnixListener> {
let listener = UnixListener::bind_addr(addr).context("error binding socket")?;
Ok(listener)
}
fn bind_to_abstract_socket(display: u32) -> anyhow::Result<UnixListener> {
let name = format!("/tmp/.X11-unix/X{display}");
let addr = SocketAddr::from_abstract_name(name).unwrap();
bind_to_socket(&addr)
}
fn bind_to_unix_socket(display: u32) -> anyhow::Result<(UnixListener, Unlink)> {
let name = format!("/tmp/.X11-unix/X{display}");
let addr = SocketAddr::from_pathname(&name).unwrap();
// Unlink old leftover socket if any.
let _ = unlink(&name);
let guard = Unlink(name);
bind_to_socket(&addr).map(|listener| (listener, guard))
}
fn open_display_sockets(display: u32) -> anyhow::Result<(UnixListener, UnixListener, Unlink)> {
let a = bind_to_abstract_socket(display).context("error binding to abstract socket")?;
let (u, g) = bind_to_unix_socket(display).context("error binding to unix socket")?;
Ok((a, u, g))
}
fn setup_connection() -> anyhow::Result<X11Connection> {
let _span = tracy_client::span!("open_x11_sockets");
ensure_x11_unix_dir()?;
let mut n = 0;
let mut attempt = 0;
let (display, lock_guard, a, u, unix_guard) = loop {
let (display, lock_fd, lock_guard) = pick_x11_display(n)?;
// Write our PID into the lock file.
let pid_string = format!("{:>10}\n", getpid().as_raw_nonzero());
if let Err(err) = rustix::io::write(&lock_fd, pid_string.as_bytes()) {
return Err(err).context("error writing PID to X11 lock file");
}
drop(lock_fd);
match open_display_sockets(display) {
Ok((a, u, g)) => {
break (display, lock_guard, a, u, g);
}
Err(err) => {
if attempt == 50 {
return Err(err)
.context("error opening X11 sockets after creating a lock file");
}
n = display + 1;
attempt += 1;
continue;
}
}
};
let display_name = format!(":{display}");
let abstract_fd = OwnedFd::from(a);
let unix_fd = OwnedFd::from(u);
Ok(X11Connection {
display_name,
abstract_fd,
unix_fd,
_unix_guard: unix_guard,
_lock_guard: lock_guard,
})
}

View file

@ -0,0 +1,309 @@
use std::os::fd::{AsRawFd as _, BorrowedFd, OwnedFd};
use std::os::unix::net::UnixListener;
use std::os::unix::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use calloop::channel::Sender;
use calloop::generic::Generic;
use calloop::{Interest, Mode, PostAction, RegistrationToken};
use smithay::reexports::rustix::io::{fcntl_setfd, FdFlags};
use crate::niri::State;
use crate::utils::expand_home;
use crate::utils::xwayland::X11Connection;
pub struct Satellite {
x11: X11Connection,
abstract_token: Option<RegistrationToken>,
unix_token: Option<RegistrationToken>,
to_main: Sender<ToMain>,
}
enum ToMain {
SetupWatch,
}
impl Satellite {
pub fn display_name(&self) -> &str {
&self.x11.display_name
}
}
pub fn setup(state: &mut State) {
if state.niri.satellite.is_some() {
return;
}
let config = state.niri.config.borrow();
let xwls_config = &config.xwayland_satellite;
if xwls_config.off {
return;
}
if !test_ondemand(&xwls_config.path) {
return;
}
drop(config);
let x11 = match super::setup_connection() {
Ok(x11) => x11,
Err(err) => {
warn!("error opening X11 sockets, disabling xwayland-satellite integration: {err:?}");
return;
}
};
let event_loop = &state.niri.event_loop;
let (to_main, rx) = calloop::channel::channel();
event_loop
.insert_source(rx, move |event, _, state| match event {
calloop::channel::Event::Msg(msg) => match msg {
ToMain::SetupWatch => setup_watch(state),
},
calloop::channel::Event::Closed => (),
})
.unwrap();
state.niri.satellite = Some(Satellite {
x11,
abstract_token: None,
unix_token: None,
to_main,
});
setup_watch(state);
}
fn test_ondemand(path: &str) -> bool {
let _span = tracy_client::span!("satellite::test_ondemand");
// Expand `~` at the start.
let mut path = Path::new(path);
let expanded = expand_home(path);
match &expanded {
Ok(Some(expanded)) => path = expanded.as_ref(),
Ok(None) => (),
Err(err) => {
warn!("error expanding ~: {err:?}");
}
}
let mut process = Command::new(path);
process
.args([":0", "--test-listenfd-support"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.env_remove("DISPLAY")
.env_remove("RUST_BACKTRACE")
.env_remove("RUST_LIB_BACKTRACE");
let mut child = match process.spawn() {
Ok(child) => child,
Err(err) => {
info!("error spawning xwayland-satellite at {path:?}, disabling integration: {err}");
return false;
}
};
let status = match child.wait() {
Ok(status) => status,
Err(err) => {
info!("error waiting for xwayland-satellite, disabling integration: {err}");
return false;
}
};
if !status.success() {
info!("xwayland-satellite doesn't support on-demand activation, disabling integration");
return false;
}
true
}
// When xwayland-satellite fails to start and accept a connection on the socket, the socket will
// keep triggering our event source, even after the X11 client quits, resulting in a busyloop of
// trying to start xwayland-satellite. This function will clear out (accept and drop) all pending
// connections on the socket before registering a new event source, working around this problem.
// When the problem happens, it's very likely that xwayland-satellite won't be able to accept the
// pending client (since it had just failed to do so), so it's fine to drop the connections.
fn clear_out_pending_connections(fd: OwnedFd) -> OwnedFd {
let listener = UnixListener::from(fd);
if let Err(err) = listener.set_nonblocking(true) {
warn!("error setting X11 socket to nonblocking: {err:?}");
return OwnedFd::from(listener);
}
while listener.accept().is_ok() {}
if let Err(err) = listener.set_nonblocking(false) {
warn!("error setting X11 socket to blocking: {err:?}");
}
OwnedFd::from(listener)
}
fn setup_watch(state: &mut State) {
let Some(satellite) = state.niri.satellite.as_mut() else {
return;
};
let event_loop = &state.niri.event_loop;
if let Some(token) = satellite.abstract_token.take() {
error!("abstract_token must be None in setup_watch()");
event_loop.remove(token);
}
if let Some(token) = satellite.unix_token.take() {
error!("unix_token must be None in setup_watch()");
event_loop.remove(token);
}
let fd = satellite.x11.abstract_fd.try_clone().unwrap();
let fd = clear_out_pending_connections(fd);
let source = Generic::new(fd, Interest::READ, Mode::Level);
let token = event_loop
.insert_source(source, move |_, _, state| {
if let Some(satellite) = &mut state.niri.satellite {
// Remove the other source.
if let Some(token) = satellite.unix_token.take() {
state.niri.event_loop.remove(token);
}
// Clear this source.
satellite.abstract_token = None;
debug!("connection to X11 abstract socket; spawning xwayland-satellite");
let path = state.niri.config.borrow().xwayland_satellite.path.clone();
spawn(path, satellite);
}
Ok(PostAction::Remove)
})
.unwrap();
satellite.abstract_token = Some(token);
let fd = satellite.x11.unix_fd.try_clone().unwrap();
let fd = clear_out_pending_connections(fd);
let source = Generic::new(fd, Interest::READ, Mode::Level);
let token = event_loop
.insert_source(source, move |_, _, state| {
if let Some(satellite) = &mut state.niri.satellite {
// Remove the other source.
if let Some(token) = satellite.abstract_token.take() {
state.niri.event_loop.remove(token);
}
// Clear this source.
satellite.unix_token = None;
debug!("connection to X11 unix socket; spawning xwayland-satellite");
let path = state.niri.config.borrow().xwayland_satellite.path.clone();
spawn(path, satellite);
}
Ok(PostAction::Remove)
})
.unwrap();
satellite.unix_token = Some(token);
}
fn spawn(path: String, xwl: &Satellite) {
let _span = tracy_client::span!("satellite::spawn");
let abstract_fd = xwl.x11.abstract_fd.try_clone().unwrap();
let unix_fd = xwl.x11.unix_fd.try_clone().unwrap();
let to_main = xwl.to_main.clone();
// Expand `~` at the start.
let mut path = PathBuf::from(path);
let expanded = expand_home(&path);
match expanded {
Ok(Some(expanded)) => path = expanded,
Ok(None) => (),
Err(err) => {
warn!("error expanding ~: {err:?}");
}
}
let mut process = Command::new(&path);
process.arg(&xwl.x11.display_name).env_remove("DISPLAY");
// We don't want it spamming the niri output.
process
.env_remove("RUST_BACKTRACE")
.env_remove("RUST_LIB_BACKTRACE");
process
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
// Spawning and waiting takes some milliseconds, so do it in a thread.
let res = thread::Builder::new()
.name("Xwl-s Spawner".to_owned())
.spawn(move || {
spawn_and_wait(&path, process, abstract_fd, unix_fd);
// Once xwayland-satellite crashes or fails to spawn, re-establish our X11 socket watch
// to try again next time.
let _ = to_main.send(ToMain::SetupWatch);
});
if let Err(err) = res {
warn!("error spawning a thread to spawn xwayland-satellite: {err:?}");
let _ = xwl.to_main.send(ToMain::SetupWatch);
}
}
fn spawn_and_wait(path: &Path, mut process: Command, abstract_fd: OwnedFd, unix_fd: OwnedFd) {
let abstract_raw = abstract_fd.as_raw_fd();
let unix_raw = unix_fd.as_raw_fd();
process
.arg("-listenfd")
.arg(abstract_raw.to_string())
.arg("-listenfd")
.arg(unix_raw.to_string());
unsafe {
process.pre_exec(move || {
// We're about to exec xwl-s; perfect time to clear CLOEXEC on the file descriptors
// that we want to pass it.
// We're not dropping these until after spawn().
let abstract_fd = BorrowedFd::borrow_raw(abstract_raw);
let unix_fd = BorrowedFd::borrow_raw(unix_raw);
fcntl_setfd(abstract_fd, FdFlags::empty())?;
fcntl_setfd(unix_fd, FdFlags::empty())?;
Ok(())
})
};
let mut child = {
let _span = tracy_client::span!();
match process.spawn() {
Ok(child) => child,
Err(err) => {
warn!("error spawning {path:?}: {err:?}");
return;
}
}
};
// The process spawned, we can drop our fds.
drop(abstract_fd);
drop(unix_fd);
let status = match child.wait() {
Ok(status) => status,
Err(err) => {
warn!("error waiting for xwayland-satellite: {err:?}");
return;
}
};
// This is most likely a crash, hence warn!().
warn!("xwayland-satellite exited with: {status}");
}

View file

@ -36,6 +36,11 @@ overview {
}
}
xwayland-satellite {
// off
path "xwayland-satellite"
}
clipboard {
disable-primary
}
@ -207,6 +212,28 @@ overview {
}
```
### `xwayland-satellite`
<sup>Since: next release</sup>
Settings for integration with [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite).
When a recent enough xwayland-satellite is detected, niri will create the X11 sockets and set `DISPLAY`, then automatically spawn `xwayland-satellite` when an X11 client tries to connect.
If Xwayland dies, niri will keep watching the X11 socket and restart `xwayland-satellite` as needed.
This is very similar to how built-in Xwayland works in other compositors.
`off` disables the integration: niri won't create an X11 socket and won't set the `DISPLAY` environment variable.
`path` sets the path to the `xwayland-satellite` binary.
By default, it's just `xwayland-satellite`, so it's looked up like any other non-absolute program name.
```kdl
// Use a custom build of xwayland-satellite.
xwayland-satellite {
path "~/source/rs/xwayland-satellite/target/release/xwayland-satellite"
}
```
### `clipboard`
<sup>Since: 25.02</sup>

View file

@ -8,6 +8,14 @@ It makes X11 windows appear as normal windows, just like a native Xwayland integ
xwayland-satellite works well with most applications: Steam, games, Discord, even more exotic things like Ardour with wine Windows VST plugins.
However, X11 apps that want to position windows or bars at specific screen coordinates won't behave correctly.
> [!NOTE]
> In the next release, niri will have [built-in xwayland-satellite integration](./Configuration:-Miscellaneous.md#xwayland-satellite).
> You can try it by installing git versions of both niri and xwayland-satellite.
> With no further configuration, niri will create X11 sockets, then when an X11 client connects, automatically start xwayland-satellite.
>
> This matches how other compositors run Xwayland (but in niri's case, it's xwayland-satellite rather than Xwayland itself).
> It also makes X11 apps work fine in `spawn-at-startup` and in XDG autostart.
Install it from your package manager, or build it according to instructions from its README, then run the `xwayland-satellite` binary.
Look for a log message like: `Connected to Xwayland on :0`.
Now you can start X11 applications on this X11 DISPLAY: