feat(vdisplay/session): derive SWAYSOCK — the last session var a --user host lacked

Session detection already hands every backend the live session's env:
WAYLAND_DISPLAY from a socket scan, XDG_RUNTIME_DIR from the uid, the
session bus, XDG_CURRENT_DESKTOP, and Hyprland's instance signature. sway
was the exception — SWAYSOCK had to be INHERITED from the login shell, so
a systemd --user host that never saw one had no sway IPC at all: no
output enumeration, no capture chooser, no wlroots backend (its
is_available() keys off that very variable).

It is derivable, and by identity rather than guess: sway names its socket
sway-ipc.<uid>.<pid>.sock, and detection already knows the compositor
PID. Ladder: a valid inherited value, then the exact PID's socket, then
the newest one we own (a sway re-exec can leave the name pointing at a
PID we didn't see). None on river — the other wlroots desktop ships no
sway IPC, and saying so is what stops apply_session_env exporting a
SWAYSOCK that points at nothing.

Closes the session-env half of design/per-monitor-portal-capture.md §6
for a desktop-login host: nothing has to be exported for the host to find
its session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:51:41 +02:00
co-authored by Claude Opus 5
parent 829bcb7962
commit 6afc05155b
+159 -1
View File
@@ -163,8 +163,15 @@ pub struct SessionEnv {
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for /// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket; /// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the /// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor. /// session env. `None` for every other compositor.
pub hyprland_signature: Option<String>, pub hyprland_signature: Option<String>,
/// `SWAYSOCK` of the live sway instance (`Some` only for a sway [`ActiveKind::DesktopWlroots`]).
/// `swaymsg` needs it, and it was the LAST session variable the host could not derive: a
/// `systemd --user` host that never inherited the login shell's environment had no sway IPC at
/// all, so output enumeration and the chooser both failed. Derived from the detected compositor
/// PID like the Hyprland signature above. `None` on river (wlroots, but no sway IPC) and every
/// other compositor.
pub sway_socket: Option<String>,
} }
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it. /// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
@@ -316,6 +323,12 @@ pub fn detect_active_session() -> ActiveSession {
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid), ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
_ => None, _ => None,
}; };
// Same idea for sway's IPC socket: without it `swaymsg` has nothing to talk to, and a
// `systemd --user` host never inherited it.
let sway_socket = match kind {
ActiveKind::DesktopWlroots => find_sway_socket(&xdg_runtime_dir, uid, winning_pid),
_ => None,
};
ActiveSession { ActiveSession {
kind, kind,
env: SessionEnv { env: SessionEnv {
@@ -324,6 +337,7 @@ pub fn detect_active_session() -> ActiveSession {
dbus_session_bus_address: dbus, dbus_session_bus_address: dbus,
xdg_current_desktop, xdg_current_desktop,
hyprland_signature, hyprland_signature,
sway_socket,
}, },
compositor_pid: winning_pid, compositor_pid: winning_pid,
} }
@@ -361,6 +375,46 @@ fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
cands.into_iter().next().map(|(_, n)| n) cands.into_iter().next().map(|(_, n)| n)
} }
/// Find the live sway IPC socket (`SWAYSOCK`) for our uid. Trust a valid inherited value first (the
/// host launched inside the session); then the exact `sway-ipc.<uid>.<pid>.sock` for the compositor
/// PID detection already picked — a name sway builds from those two numbers, so it is an identity
/// match rather than a guess; then the newest-mtime `sway-ipc.<uid>.*.sock` we own, for the case
/// where the socket name does not match the PID we saw (sway re-exec, a wrapper process).
///
/// `None` on river: it is the other [`ActiveKind::DesktopWlroots`] compositor and has no sway IPC —
/// which is the honest answer, since the wlroots backend drives sway through `swaymsg`.
#[cfg(target_os = "linux")]
fn find_sway_socket(runtime: &str, uid: u32, pid: Option<u32>) -> Option<String> {
use std::os::unix::fs::MetadataExt;
if let Ok(s) = std::env::var("SWAYSOCK") {
if !s.is_empty() && std::path::Path::new(&s).exists() {
return Some(s);
}
}
if let Some(pid) = pid {
let exact = std::path::Path::new(runtime).join(format!("sway-ipc.{uid}.{pid}.sock"));
if exact.exists() {
return Some(exact.to_string_lossy().into_owned());
}
}
let prefix = format!("sway-ipc.{uid}.");
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
for e in std::fs::read_dir(runtime).ok()?.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.starts_with(&prefix) || !name.ends_with(".sock") {
continue;
}
let Ok(md) = e.metadata() else { continue };
if md.uid() != uid {
continue;
}
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
cands.push((mtime, e.path().to_string_lossy().into_owned()));
}
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
cands.into_iter().next().map(|(_, p)| p)
}
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn detect_active_session() -> ActiveSession { pub fn detect_active_session() -> ActiveSession {
ActiveSession::none() ActiveSession::none()
@@ -426,6 +480,15 @@ pub fn apply_session_env(active: &ActiveSession) {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig), Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"), None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
} }
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the capture
// chooser) is unreachable without it, so a systemd `--user` host that never inherited the login
// environment had no sway backend at all. Cleared when nothing sway-shaped is live, so a
// sway→Hyprland switch can't leave `swaymsg` aimed at a dead socket. `wlroots::is_available()`
// keys off this variable, so setting it here is also what makes the backend visible at all.
match &e.sway_socket {
Some(sock) => std::env::set_var("SWAYSOCK", sock),
None => std::env::remove_var("SWAYSOCK"),
}
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous // NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash // connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept // (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
@@ -564,3 +627,98 @@ pub fn settle_desktop_portal(chosen: Compositor) {
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn settle_desktop_portal(_chosen: Compositor) {} pub fn settle_desktop_portal(_chosen: Compositor) {}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
/// A scratch runtime dir with the sway-ipc sockets named in `pids`, plus the uid the names are
/// built from. Removed on drop.
struct FakeRuntime {
dir: std::path::PathBuf,
uid: u32,
}
impl FakeRuntime {
fn new(tag: &str, pids: &[u32]) -> FakeRuntime {
// SAFETY: parameterless POSIX call, returns the calling process's uid; touches no memory.
let uid = unsafe { libc::getuid() };
let dir =
std::env::temp_dir().join(format!("pf-swaysock-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for pid in pids {
std::fs::write(dir.join(format!("sway-ipc.{uid}.{pid}.sock")), b"").unwrap();
}
FakeRuntime { dir, uid }
}
fn path(&self) -> &str {
self.dir.to_str().unwrap()
}
}
impl Drop for FakeRuntime {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Run `f` with `SWAYSOCK` unset, so the "trust what we inherited" rung can't decide the test.
/// Serialized on the crate's env lock — these tests mutate process-global state.
fn without_inherited_swaysock<R>(f: impl FnOnce() -> R) -> R {
crate::with_env_lock(|| {
let prev = std::env::var_os("SWAYSOCK");
std::env::remove_var("SWAYSOCK");
let out = f();
if let Some(p) = prev {
std::env::set_var("SWAYSOCK", p);
}
out
})
}
/// The point of deriving it: the socket that belongs to the compositor detection actually found,
/// not merely *a* sway socket. A stale socket from a previous sway (crash, re-login) sitting in
/// the same runtime dir must not win.
#[test]
fn the_socket_matching_the_detected_pid_wins() {
let rt = FakeRuntime::new("exact", &[4242, 9999]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(4242)));
assert_eq!(
got,
Some(format!("{}/sway-ipc.{}.4242.sock", rt.path(), rt.uid))
);
}
/// sway re-exec (or a wrapper) can leave the socket named for a PID we didn't see. One socket in
/// the dir is still unambiguous — better to hand `swaymsg` the real thing than nothing.
#[test]
fn an_unmatched_pid_falls_back_to_the_socket_that_is_there() {
let rt = FakeRuntime::new("fallback", &[777]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(12345)));
assert_eq!(
got,
Some(format!("{}/sway-ipc.{}.777.sock", rt.path(), rt.uid))
);
}
/// river is the other wlroots desktop and ships no sway IPC. Reporting `None` is what keeps
/// `apply_session_env` from exporting a `SWAYSOCK` that points at nothing — an exported lie
/// would make `wlroots::is_available()` claim a backend that cannot answer.
#[test]
fn no_sway_ipc_socket_reports_none() {
let rt = FakeRuntime::new("none", &[]);
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(1)));
assert_eq!(got, None);
}
/// Someone else's socket in a shared runtime dir is not ours to talk to.
#[test]
fn another_uids_socket_is_ignored() {
let rt = FakeRuntime::new("otheruid", &[]);
let other = rt.uid.wrapping_add(1);
std::fs::write(rt.dir.join(format!("sway-ipc.{other}.500.sock")), b"").unwrap();
let got = without_inherited_swaysock(|| find_sway_socket(rt.path(), rt.uid, Some(500)));
assert_eq!(got, None);
}
}