From 24efcb157d61dd28642e46a327249e7f9be3e62e Mon Sep 17 00:00:00 2001 From: TheSkyentist Date: Mon, 24 Aug 2026 10:31:45 +0200 Subject: [PATCH 1/3] server: detect graphical sessions under systemd --user compositors Add two fallbacks to SessionType detection, used only when logind can't place the peer in a session at all: reading the peer's own /proc//environ for WAYLAND_DISPLAY/DISPLAY, and, since that is commonly blocked by Yama's ptrace_scope, falling back further to the systemd --user manager's own exported environment, which compositors following this integration model populate on startup and which is readable over D-Bus without needing ptrace access. --- server/src/service/mod.rs | 4 +- server/src/session.rs | 129 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 87407d22..9f8c524e 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -134,9 +134,7 @@ impl Service { .file_name()? .to_str()? .to_owned(); - let session_type = SessionType::from_logind(pid) - .await - .unwrap_or(SessionType::Unspecified); + let session_type = SessionType::detect(pid).await; Some(PeerInfo::new(pid, name, session_type)) } .await; diff --git a/server/src/session.rs b/server/src/session.rs index e7ec180a..6839d79b 100644 --- a/server/src/session.rs +++ b/server/src/session.rs @@ -49,6 +49,75 @@ impl SessionType { _ => Self::Unspecified, }) } + + /// Fall back to the peer's own environment, for compositors run as a + /// systemd `--user` service (e.g. niri, sway), which `logind` can't + /// place in a session at all. Often blocked by Yama's `ptrace_scope`, + /// since the daemon isn't an ancestor of its peers; see + /// [`Self::from_systemd_user_environment`] for the last resort. + pub async fn from_environ(pid: u32) -> Option { + let environ = tokio::fs::read(format!("/proc/{pid}/environ")).await.ok()?; + let has_non_empty_var = |name: &str| { + let prefix = format!("{name}="); + environ + .split(|&b| b == 0) + .any(|entry| entry.len() > prefix.len() && entry.starts_with(prefix.as_bytes())) + }; + + if has_non_empty_var("WAYLAND_DISPLAY") { + Some(Self::Wayland) + } else if has_non_empty_var("DISPLAY") { + Some(Self::X11) + } else { + None + } + } + + /// Last resort: the systemd `--user` manager's own exported + /// environment. Systemd-integrated compositors import + /// `WAYLAND_DISPLAY`/`DISPLAY` into it on startup (e.g. via + /// `dbus-update-activation-environment`), and it's readable over + /// D-Bus with no `ptrace_scope` restriction. Coarser than the other + /// checks since it's session-wide rather than peer-specific, so it's + /// only consulted once `logind` can't place the peer anywhere. + pub async fn from_systemd_user_environment() -> Option { + let connection = zbus::Connection::session().await.ok()?; + let manager = SystemdManagerProxy::new(&connection).await.ok()?; + let environment = manager.environment().await.ok()?; + + let has_non_empty_var = |name: &str| { + let prefix = format!("{name}="); + environment + .iter() + .any(|entry| entry.len() > prefix.len() && entry.starts_with(&prefix)) + }; + + if has_non_empty_var("WAYLAND_DISPLAY") { + Some(Self::Wayland) + } else if has_non_empty_var("DISPLAY") { + Some(Self::X11) + } else { + None + } + } + + /// Best-effort session type detection, cascading `logind` -> + /// [`Self::from_environ`] -> [`Self::from_systemd_user_environment`]. + /// Stops at the first check that places the peer in a session at + /// all, even a non-graphical one. + pub async fn detect(pid: u32) -> Self { + if let Some(session_type) = Self::from_logind(pid).await { + return session_type; + } + + if let Some(session_type) = Self::from_environ(pid).await { + return session_type; + } + + Self::from_systemd_user_environment() + .await + .unwrap_or(Self::Unspecified) + } } #[zbus::proxy( @@ -71,6 +140,17 @@ trait LoginSession { fn type_(&self) -> zbus::Result; } +#[zbus::proxy( + default_service = "org.freedesktop.systemd1", + interface = "org.freedesktop.systemd1.Manager", + default_path = "/org/freedesktop/systemd1", + gen_blocking = false +)] +trait SystemdManager { + #[zbus(property)] + fn environment(&self) -> zbus::Result>; +} + #[derive(Debug, Clone)] pub struct PeerInfo { pid: u32, @@ -177,6 +257,55 @@ impl Session { mod tests { use crate::tests::TestServiceSetup; + use super::SessionType; + + /// Spawn a short-lived child process with a controlled environment, to + /// exercise `SessionType::from_environ` against a real `/proc//environ`. + fn spawn_with_env(vars: &[(&str, &str)]) -> std::process::Child { + let mut command = std::process::Command::new("sleep"); + command.arg("5").env_clear(); + for (key, value) in vars { + command.env(key, value); + } + command.spawn().expect("failed to spawn test child process") + } + + #[tokio::test] + async fn from_environ_detects_wayland() { + let mut child = spawn_with_env(&[("WAYLAND_DISPLAY", "wayland-test")]); + + let session_type = SessionType::from_environ(child.id()).await; + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(session_type, Some(SessionType::Wayland)); + } + + #[tokio::test] + async fn from_environ_detects_x11() { + let mut child = spawn_with_env(&[("DISPLAY", ":0")]); + + let session_type = SessionType::from_environ(child.id()).await; + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(session_type, Some(SessionType::X11)); + } + + #[tokio::test] + async fn from_environ_none_without_display() { + let mut child = spawn_with_env(&[]); + + let session_type = SessionType::from_environ(child.id()).await; + + let _ = child.kill(); + let _ = child.wait(); + + assert_eq!(session_type, None); + } + #[tokio::test] async fn close() -> Result<(), Box> { let setup = TestServiceSetup::plain_session(true).await?; From 80df49c2cfe3367e7ab135a5bb20397dc2ba94a9 Mon Sep 17 00:00:00 2001 From: TheSkyentist Date: Mon, 24 Aug 2026 15:30:26 +0200 Subject: [PATCH 2/3] server: address review feedback on session detection Extract the WAYLAND_DISPLAY/DISPLAY lookup shared by from_environ and from_systemd_user_environment into from_display_vars, add unit tests for it, drop the unneeded allocation, and make both helpers private since only detect() is used externally. --- server/src/session.rs | 89 +++++++++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/server/src/session.rs b/server/src/session.rs index 6839d79b..0a19d0bd 100644 --- a/server/src/session.rs +++ b/server/src/session.rs @@ -55,22 +55,9 @@ impl SessionType { /// place in a session at all. Often blocked by Yama's `ptrace_scope`, /// since the daemon isn't an ancestor of its peers; see /// [`Self::from_systemd_user_environment`] for the last resort. - pub async fn from_environ(pid: u32) -> Option { + async fn from_environ(pid: u32) -> Option { let environ = tokio::fs::read(format!("/proc/{pid}/environ")).await.ok()?; - let has_non_empty_var = |name: &str| { - let prefix = format!("{name}="); - environ - .split(|&b| b == 0) - .any(|entry| entry.len() > prefix.len() && entry.starts_with(prefix.as_bytes())) - }; - - if has_non_empty_var("WAYLAND_DISPLAY") { - Some(Self::Wayland) - } else if has_non_empty_var("DISPLAY") { - Some(Self::X11) - } else { - None - } + Self::from_display_vars(environ.split(|&b| b == 0)) } /// Last resort: the systemd `--user` manager's own exported @@ -80,21 +67,31 @@ impl SessionType { /// D-Bus with no `ptrace_scope` restriction. Coarser than the other /// checks since it's session-wide rather than peer-specific, so it's /// only consulted once `logind` can't place the peer anywhere. - pub async fn from_systemd_user_environment() -> Option { + async fn from_systemd_user_environment() -> Option { let connection = zbus::Connection::session().await.ok()?; let manager = SystemdManagerProxy::new(&connection).await.ok()?; let environment = manager.environment().await.ok()?; - let has_non_empty_var = |name: &str| { - let prefix = format!("{name}="); - environment - .iter() - .any(|entry| entry.len() > prefix.len() && entry.starts_with(&prefix)) - }; + Self::from_display_vars(environment.iter().map(String::as_bytes)) + } - if has_non_empty_var("WAYLAND_DISPLAY") { + /// Shared `WAYLAND_DISPLAY`/`DISPLAY` lookup over a set of `NAME=value` + /// entries, as found in both `/proc//environ` and the systemd + /// `--user` manager's exported environment. + fn from_display_vars<'a>(vars: impl Iterator) -> Option { + let mut wayland = false; + let mut x11 = false; + for entry in vars { + if let Some(value) = entry.strip_prefix(b"WAYLAND_DISPLAY=") { + wayland |= !value.is_empty(); + } else if let Some(value) = entry.strip_prefix(b"DISPLAY=") { + x11 |= !value.is_empty(); + } + } + + if wayland { Some(Self::Wayland) - } else if has_non_empty_var("DISPLAY") { + } else if x11 { Some(Self::X11) } else { None @@ -306,6 +303,50 @@ mod tests { assert_eq!(session_type, None); } + #[test] + fn from_display_vars_detects_wayland() { + let vars = [b"WAYLAND_DISPLAY=wayland-test".as_slice(), b"FOO=bar"]; + + assert_eq!( + SessionType::from_display_vars(vars.into_iter()), + Some(SessionType::Wayland) + ); + } + + #[test] + fn from_display_vars_detects_x11() { + let vars = [b"DISPLAY=:0".as_slice(), b"FOO=bar"]; + + assert_eq!( + SessionType::from_display_vars(vars.into_iter()), + Some(SessionType::X11) + ); + } + + #[test] + fn from_display_vars_prefers_wayland_over_x11() { + let vars = [b"DISPLAY=:0".as_slice(), b"WAYLAND_DISPLAY=wayland-test"]; + + assert_eq!( + SessionType::from_display_vars(vars.into_iter()), + Some(SessionType::Wayland) + ); + } + + #[test] + fn from_display_vars_ignores_empty_values() { + let vars = [b"WAYLAND_DISPLAY=".as_slice(), b"DISPLAY="]; + + assert_eq!(SessionType::from_display_vars(vars.into_iter()), None); + } + + #[test] + fn from_display_vars_none_without_display() { + let vars = [b"FOO=bar".as_slice()]; + + assert_eq!(SessionType::from_display_vars(vars.into_iter()), None); + } + #[tokio::test] async fn close() -> Result<(), Box> { let setup = TestServiceSetup::plain_session(true).await?; From 31cbe45d4abc85a3409ab6ffa71186bf7a2ea501 Mon Sep 17 00:00:00 2001 From: TheSkyentist Date: Mon, 24 Aug 2026 15:34:44 +0200 Subject: [PATCH 3/3] server: fix rustfmt (nightly) formatting --- server/src/session.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/session.rs b/server/src/session.rs index 0a19d0bd..86258611 100644 --- a/server/src/session.rs +++ b/server/src/session.rs @@ -252,12 +252,12 @@ impl Session { #[cfg(test)] mod tests { - use crate::tests::TestServiceSetup; - use super::SessionType; + use crate::tests::TestServiceSetup; /// Spawn a short-lived child process with a controlled environment, to - /// exercise `SessionType::from_environ` against a real `/proc//environ`. + /// exercise `SessionType::from_environ` against a real + /// `/proc//environ`. fn spawn_with_env(vars: &[(&str, &str)]) -> std::process::Child { let mut command = std::process::Command::new("sleep"); command.arg("5").env_clear();