refactor(host/W3): split vdisplay session detection + gamescope routing out of the spine
apple / swift (push) Successful in 1m22s
apple / screenshots (push) Successful in 4m39s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m5s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m10s
arch / build-publish (push) Successful in 16m17s
android / android (push) Successful in 17m14s
windows-host / package (push) Successful in 20m51s
deb / build-publish (push) Successful in 12m23s
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
apple / swift (push) Successful in 1m22s
apple / screenshots (push) Successful in 4m39s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m5s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m10s
arch / build-publish (push) Successful in 16m17s
android / android (push) Successful in 17m14s
windows-host / package (push) Successful in 20m51s
deb / build-publish (push) Successful in 12m23s
ci / rust (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Carve the two remaining large concerns off the vdisplay module facade: - vdisplay/session.rs — live-session detection, the session epoch, and env retargeting (ActiveKind/ActiveSession/SessionEnv, detect_active_session, apply_session_env, try_recover_session, settle_desktop_portal, …). - vdisplay/routing.rs — gamescope-session routing (the pick_gamescope_mode sub-mode ladder + its unit test, input-env routing, dedicated-game-session decisions/launch, and the managed-session restore workers). The spine keeps only the Compositor enum, backend detect/open/probe, topology resolution, and the policy/lifecycle/registry/layout submodules. Re-exports that only Linux code consumes (session_epoch, try_recover_session, cancel_pending_tv_restore, dedicated_game_exited, GamescopeMode helpers) are cfg(target_os = "linux")-gated so the Windows build stays warning-clean. Pure move; no behavior change. Linux clippy+tests and Windows host clippy (nvenc,amf-qsv) both green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,82 +28,28 @@ use std::os::fd::OwnedFd;
|
|||||||
pub(crate) mod backend;
|
pub(crate) mod backend;
|
||||||
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||||
|
|
||||||
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
/// Live-session detection + session-epoch + env retargeting (plan §W3).
|
||||||
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
#[path = "vdisplay/session.rs"]
|
||||||
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
pub(crate) mod session;
|
||||||
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
|
pub use session::{
|
||||||
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
|
apply_session_env, compositor_for_kind, detect_active_session, observe_session_instance,
|
||||||
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
|
settle_desktop_portal, ActiveKind, ActiveSession, SessionEnv,
|
||||||
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use session::{session_epoch, try_recover_session};
|
||||||
|
|
||||||
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
|
/// Gamescope-session routing (plan §W3).
|
||||||
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
|
#[path = "vdisplay/routing.rs"]
|
||||||
pub fn session_epoch() -> u64 {
|
pub(crate) mod routing;
|
||||||
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
|
pub use routing::{
|
||||||
}
|
apply_input_env, restore_managed_session, restore_takeover_on_startup, start_restore_worker,
|
||||||
|
wants_dedicated_game_session,
|
||||||
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
|
};
|
||||||
/// instance (kind change, or same-kind new PID). Returns the new value.
|
#[cfg(target_os = "linux")]
|
||||||
pub fn bump_session_epoch() -> u64 {
|
pub use routing::{
|
||||||
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
|
cancel_pending_tv_restore, dedicated_game_exited, gamescope_ei_socket_file,
|
||||||
}
|
launch_into_gamescope_session, launch_is_nested,
|
||||||
|
};
|
||||||
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
|
|
||||||
/// genuine instance change from a stable re-detect.
|
|
||||||
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
|
|
||||||
std::sync::Mutex::new(None);
|
|
||||||
|
|
||||||
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
|
|
||||||
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
|
|
||||||
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
|
|
||||||
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
|
|
||||||
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
|
|
||||||
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
|
|
||||||
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
|
|
||||||
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
|
|
||||||
pub fn observe_session_instance(active: &ActiveSession) {
|
|
||||||
let cur = (active.kind, active.compositor_pid);
|
|
||||||
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
if let Some(prev) = *last {
|
|
||||||
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
|
|
||||||
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
|
|
||||||
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
|
|
||||||
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
|
|
||||||
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
|
|
||||||
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
|
|
||||||
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
|
|
||||||
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
|
|
||||||
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
|
|
||||||
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
|
|
||||||
if is_desktop_kind(prev.0) {
|
|
||||||
if let Some(old) = compositor_for_kind(prev.0) {
|
|
||||||
registry::invalidate_backend(old.id());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let epoch = bump_session_epoch();
|
|
||||||
tracing::info!(
|
|
||||||
from = ?prev.0,
|
|
||||||
to = ?cur.0,
|
|
||||||
epoch,
|
|
||||||
"desktop compositor instance changed — session epoch bumped"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*last = Some(cur);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
|
||||||
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
|
||||||
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
|
||||||
fn is_desktop_kind(kind: ActiveKind) -> bool {
|
|
||||||
matches!(
|
|
||||||
kind,
|
|
||||||
ActiveKind::DesktopKde
|
|
||||||
| ActiveKind::DesktopGnome
|
|
||||||
| ActiveKind::DesktopWlroots
|
|
||||||
| ActiveKind::DesktopHyprland
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compositors punktfunk knows how to drive (plan §6).
|
/// Compositors punktfunk knows how to drive (plan §6).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -219,284 +165,6 @@ pub fn available() -> Vec<Compositor> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The kind of graphical session live for our uid *right now* — the basis for per-connect backend
|
|
||||||
/// selection on a box that flips between Steam Gaming Mode and a KDE/GNOME desktop (Bazzite,
|
|
||||||
/// SteamOS). Detected by probing which compositor process is actually running, not by a static
|
|
||||||
/// env var, so the host follows the box as the user switches sessions.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ActiveKind {
|
|
||||||
/// A `gamescope` session is live (Steam Gaming Mode / `gamescope-session-plus`).
|
|
||||||
Gaming,
|
|
||||||
/// A KWin / Plasma desktop is live.
|
|
||||||
DesktopKde,
|
|
||||||
/// A GNOME / Mutter desktop is live.
|
|
||||||
DesktopGnome,
|
|
||||||
/// A wlroots-proper (Sway / River) desktop is live.
|
|
||||||
DesktopWlroots,
|
|
||||||
/// A Hyprland desktop is live (distinct from [`DesktopWlroots`](ActiveKind::DesktopWlroots):
|
|
||||||
/// its own `hyprctl` IPC + xdph portal, though it shares the wlr virtual-input path).
|
|
||||||
DesktopHyprland,
|
|
||||||
/// No recognized graphical session is running for our uid.
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The session environment that points a backend at the [detected](detect_active_session) active
|
|
||||||
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
|
|
||||||
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
|
|
||||||
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
|
|
||||||
/// per connect and every backend that reads them then opens against the live session.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct SessionEnv {
|
|
||||||
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
|
|
||||||
/// PipeWire-node / D-Bus driven and don't talk Wayland to us).
|
|
||||||
pub wayland_display: Option<String>,
|
|
||||||
/// `/run/user/<uid>` — the trustworthy anchor (the default PipeWire daemon + bus live here).
|
|
||||||
pub xdg_runtime_dir: String,
|
|
||||||
/// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=<runtime>/bus`).
|
|
||||||
pub dbus_session_bus_address: String,
|
|
||||||
/// `XDG_CURRENT_DESKTOP` to advertise (KDE/GNOME/sway/Hyprland/gamescope) — drives portal/EIS
|
|
||||||
/// routing (xdph keys its Hyprland-specific behavior off `Hyprland`).
|
|
||||||
pub xdg_current_desktop: Option<String>,
|
|
||||||
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
|
|
||||||
/// [`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
|
|
||||||
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor.
|
|
||||||
pub hyprland_signature: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
|
|
||||||
pub struct ActiveSession {
|
|
||||||
pub kind: ActiveKind,
|
|
||||||
pub env: SessionEnv,
|
|
||||||
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
|
|
||||||
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
|
|
||||||
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
|
|
||||||
pub compositor_pid: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActiveSession {
|
|
||||||
/// A "nothing live" result carrying just the runtime-dir anchor.
|
|
||||||
fn none() -> ActiveSession {
|
|
||||||
ActiveSession {
|
|
||||||
kind: ActiveKind::None,
|
|
||||||
env: SessionEnv {
|
|
||||||
xdg_runtime_dir: default_runtime_dir(),
|
|
||||||
dbus_session_bus_address: default_bus(&default_runtime_dir()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
compositor_pid: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The concrete backend that drives a given live-session kind. `None` for [`ActiveKind::None`].
|
|
||||||
pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
|
|
||||||
match kind {
|
|
||||||
ActiveKind::Gaming => Some(Compositor::Gamescope),
|
|
||||||
ActiveKind::DesktopKde => Some(Compositor::Kwin),
|
|
||||||
ActiveKind::DesktopGnome => Some(Compositor::Mutter),
|
|
||||||
ActiveKind::DesktopWlroots => Some(Compositor::Wlroots),
|
|
||||||
ActiveKind::DesktopHyprland => Some(Compositor::Hyprland),
|
|
||||||
ActiveKind::None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn default_runtime_dir() -> String {
|
|
||||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
|
||||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
|
|
||||||
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
|
|
||||||
let uid = unsafe { libc::getuid() };
|
|
||||||
format!("/run/user/{uid}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
fn default_runtime_dir() -> String {
|
|
||||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_bus(runtime: &str) -> String {
|
|
||||||
std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap_or_else(|_| format!("unix:path={runtime}/bus"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect the graphical session live for our uid right now (cheap, side-effect-free: a `/proc`
|
|
||||||
/// scan plus a runtime-dir socket scan — well under the handshake timeout). The authority is the
|
|
||||||
/// running compositor process; a desktop compositor outranks a lingering gamescope. Used to route
|
|
||||||
/// each connect to the correct backend, and to derive the [`SessionEnv`] that targets it.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn detect_active_session() -> ActiveSession {
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
|
|
||||||
// it just returns the calling process's real uid. Nothing is aliased or freed.
|
|
||||||
let uid = unsafe { libc::getuid() };
|
|
||||||
let xdg_runtime_dir = default_runtime_dir();
|
|
||||||
let dbus = default_bus(&xdg_runtime_dir);
|
|
||||||
|
|
||||||
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
|
|
||||||
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
|
|
||||||
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
|
|
||||||
let mut kind = ActiveKind::None;
|
|
||||||
let mut best = 0u8;
|
|
||||||
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
|
|
||||||
// session epoch (A4), not just a kind change.
|
|
||||||
let mut winning_pid: Option<u32> = None;
|
|
||||||
if let Ok(entries) = std::fs::read_dir("/proc") {
|
|
||||||
for e in entries.flatten() {
|
|
||||||
let name = e.file_name();
|
|
||||||
let Some(name) = name.to_str() else { continue };
|
|
||||||
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let pid_path = e.path();
|
|
||||||
let Ok(md) = std::fs::metadata(&pid_path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if md.uid() != uid {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let (k, prio) = match comm.trim() {
|
|
||||||
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
|
|
||||||
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
|
|
||||||
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
|
|
||||||
// Hyprland is its own backend (hyprctl + xdph) — split it out of the sway/river
|
|
||||||
// wlroots-proper family (design/hyprland-support.md D1).
|
|
||||||
"Hyprland" | "hyprland" => (ActiveKind::DesktopHyprland, 4),
|
|
||||||
"sway" | "river" => (ActiveKind::DesktopWlroots, 4),
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let pid = name.parse::<u32>().ok();
|
|
||||||
if prio > best {
|
|
||||||
best = prio;
|
|
||||||
kind = k;
|
|
||||||
winning_pid = pid;
|
|
||||||
} else if prio == best {
|
|
||||||
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
|
|
||||||
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
|
|
||||||
// `/proc` enumeration order — which `observe_session_instance` would misread as a
|
|
||||||
// compositor restart and tear a live display down (re-review low-severity note).
|
|
||||||
if let (Some(p), Some(w)) = (pid, winning_pid) {
|
|
||||||
if p < w {
|
|
||||||
kind = k;
|
|
||||||
winning_pid = Some(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wayland-protocol backends (KWin, wlroots, Hyprland) need the live socket for input (the wlr
|
|
||||||
// virtual pointer/keyboard client connects to it); Gaming-attach and Mutter are node/D-Bus
|
|
||||||
// driven and don't.
|
|
||||||
let wayland_display = match kind {
|
|
||||||
ActiveKind::DesktopKde | ActiveKind::DesktopWlroots | ActiveKind::DesktopHyprland => {
|
|
||||||
find_wayland_socket(&xdg_runtime_dir, uid)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let xdg_current_desktop = match kind {
|
|
||||||
ActiveKind::DesktopKde => Some("KDE".to_string()),
|
|
||||||
ActiveKind::DesktopGnome => Some("GNOME".to_string()),
|
|
||||||
ActiveKind::DesktopWlroots => Some("sway".to_string()),
|
|
||||||
// G4: advertise the real desktop so portal routing (portals.conf `[Hyprland]`) and xdph's
|
|
||||||
// own Hyprland checks work — NOT the old blanket `sway`.
|
|
||||||
ActiveKind::DesktopHyprland => Some("Hyprland".to_string()),
|
|
||||||
ActiveKind::Gaming => Some("gamescope".to_string()),
|
|
||||||
ActiveKind::None => None,
|
|
||||||
};
|
|
||||||
// Discover the Hyprland instance signature so `hyprctl` can reach the compositor even when the
|
|
||||||
// host runs as a systemd `--user` service that never inherited the session env.
|
|
||||||
let hyprland_signature = match kind {
|
|
||||||
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
ActiveSession {
|
|
||||||
kind,
|
|
||||||
env: SessionEnv {
|
|
||||||
wayland_display,
|
|
||||||
xdg_runtime_dir,
|
|
||||||
dbus_session_bus_address: dbus,
|
|
||||||
xdg_current_desktop,
|
|
||||||
hyprland_signature,
|
|
||||||
},
|
|
||||||
compositor_pid: winning_pid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the live Hyprland instance signature (`HYPRLAND_INSTANCE_SIGNATURE`) for our uid. Trust a
|
|
||||||
/// valid inherited value first (the host launched inside the session); otherwise pick the
|
|
||||||
/// newest-mtime instance directory under `$XDG_RUNTIME_DIR/hypr/` that we own and that still has a
|
|
||||||
/// live `.socket.sock` — the same "newest wins" heuristic as [`find_wayland_socket`]. A desktop
|
|
||||||
/// normally exposes exactly one. (Phase-2 refinement: match the instance to `compositor_pid` via
|
|
||||||
/// `hyprctl instances` when several coexist — `design/hyprland-support.md` §Phase-1.1.)
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
let hypr = std::path::Path::new(runtime).join("hypr");
|
|
||||||
if let Ok(sig) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
|
||||||
if !sig.is_empty() && hypr.join(&sig).join(".socket.sock").exists() {
|
|
||||||
return Some(sig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
|
|
||||||
for e in std::fs::read_dir(&hypr).ok()?.flatten() {
|
|
||||||
let Ok(md) = e.metadata() else { continue };
|
|
||||||
if !md.is_dir() || md.uid() != uid {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !e.path().join(".socket.sock").exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let name = e.file_name().to_string_lossy().into_owned();
|
|
||||||
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
|
|
||||||
cands.push((mtime, name));
|
|
||||||
}
|
|
||||||
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
|
||||||
cands.into_iter().next().map(|(_, n)| n)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn detect_active_session() -> ActiveSession {
|
|
||||||
ActiveSession::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the live `wayland-*` socket in `runtime` for our uid (skipping `.lock` sidecars). Trust a
|
|
||||||
/// valid inherited `WAYLAND_DISPLAY` first; otherwise take the newest-mtime socket we own (a
|
|
||||||
/// desktop session normally exposes exactly one).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
if let Ok(w) = std::env::var("WAYLAND_DISPLAY") {
|
|
||||||
if !w.is_empty() {
|
|
||||||
let p = if w.starts_with('/') {
|
|
||||||
std::path::PathBuf::from(&w)
|
|
||||||
} else {
|
|
||||||
std::path::Path::new(runtime).join(&w)
|
|
||||||
};
|
|
||||||
if p.exists() {
|
|
||||||
return Some(w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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("wayland-") || name.ends_with(".lock") {
|
|
||||||
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, name));
|
|
||||||
}
|
|
||||||
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
|
||||||
cands.into_iter().next().map(|(_, n)| n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
|
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
|
||||||
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
|
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
|
||||||
/// the default concurrent native sessions each running `resolve_compositor` in its own
|
/// the default concurrent native sessions each running `resolve_compositor` in its own
|
||||||
@@ -514,338 +182,6 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
|
|||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
|
|
||||||
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
|
||||||
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
|
||||||
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
|
||||||
/// re-applies.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn apply_session_env(active: &ActiveSession) {
|
|
||||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let e = &active.env;
|
|
||||||
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
|
||||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
|
||||||
if let Some(w) = &e.wayland_display {
|
|
||||||
std::env::set_var("WAYLAND_DISPLAY", w);
|
|
||||||
}
|
|
||||||
if let Some(d) = &e.xdg_current_desktop {
|
|
||||||
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
|
||||||
}
|
|
||||||
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
|
|
||||||
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
|
|
||||||
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
|
|
||||||
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
|
|
||||||
match &e.hyprland_signature {
|
|
||||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
|
||||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
|
||||||
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
|
|
||||||
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
|
|
||||||
// graphical session" handshake error. Clear them so `available()` reports the truth and the
|
|
||||||
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
|
|
||||||
if active.kind == ActiveKind::None {
|
|
||||||
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
|
||||||
std::env::remove_var("WAYLAND_DISPLAY");
|
|
||||||
}
|
|
||||||
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
|
||||||
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
|
||||||
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
|
||||||
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
|
|
||||||
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn apply_session_env(_active: &ActiveSession) {}
|
|
||||||
|
|
||||||
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
|
|
||||||
/// connected while NO graphical session is live for this uid — the state a compositor crash
|
|
||||||
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
|
|
||||||
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
|
|
||||||
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
|
|
||||||
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
|
|
||||||
/// a recovery is underway (just launched, or launched within the debounce window), letting the
|
|
||||||
/// handshake error tell the client to simply retry.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn try_recover_session() -> bool {
|
|
||||||
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
|
|
||||||
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
|
|
||||||
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
|
|
||||||
return true; // a launch is already in flight — the retry lands in the recovered session
|
|
||||||
}
|
|
||||||
match std::process::Command::new("/bin/sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(&cmd)
|
|
||||||
.stdin(std::process::Stdio::null())
|
|
||||||
.stdout(std::process::Stdio::null())
|
|
||||||
.stderr(std::process::Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
{
|
|
||||||
Ok(mut child) => {
|
|
||||||
*last = Some(std::time::Instant::now());
|
|
||||||
tracing::warn!(cmd = %cmd,
|
|
||||||
"no live graphical session — launched the operator's session-recovery command");
|
|
||||||
// Reap off-thread so the finished child never lingers as a zombie.
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let _ = child.wait();
|
|
||||||
});
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(cmd = %cmd, error = %e,
|
|
||||||
"session-recovery command failed to launch");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn try_recover_session() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
|
|
||||||
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
|
|
||||||
/// against a half-stale env — it accepts events but they don't reach the compositor until a
|
|
||||||
/// reconnect. Push the live session env into the systemd/D-Bus activation environment and (for KWin,
|
|
||||||
/// whose input rides the xdg RemoteDesktop portal) restart the portal so it re-reads it — the same
|
|
||||||
/// settling a fresh desktop login does. Best-effort; mirrors the wlroots portal restart. GNOME uses
|
|
||||||
/// Mutter's *direct* EIS (no xdg portal), so it only needs the env push.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn settle_desktop_portal(chosen: Compositor) {
|
|
||||||
const VARS: &[&str] = &[
|
|
||||||
"WAYLAND_DISPLAY",
|
|
||||||
"XDG_CURRENT_DESKTOP",
|
|
||||||
"DBUS_SESSION_BUS_ADDRESS",
|
|
||||||
"XDG_RUNTIME_DIR",
|
|
||||||
];
|
|
||||||
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
|
|
||||||
// re-activated portal/backend inherits the live session.
|
|
||||||
let _ = std::process::Command::new("systemctl")
|
|
||||||
.args(["--user", "import-environment"])
|
|
||||||
.args(VARS)
|
|
||||||
.status();
|
|
||||||
let _ = std::process::Command::new("dbus-update-activation-environment")
|
|
||||||
.arg("--systemd")
|
|
||||||
.args(VARS)
|
|
||||||
.status();
|
|
||||||
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
|
|
||||||
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
|
|
||||||
// the now-live session, then let it settle before the injector reopens against it.
|
|
||||||
if chosen == Compositor::Kwin {
|
|
||||||
let _ = std::process::Command::new("systemctl")
|
|
||||||
.args([
|
|
||||||
"--user",
|
|
||||||
"try-restart",
|
|
||||||
"xdg-desktop-portal-kde.service",
|
|
||||||
"xdg-desktop-portal.service",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
|
||||||
}
|
|
||||||
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
|
|
||||||
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
|
|
||||||
// re-read the now-live session, mirroring the KWin settling above.
|
|
||||||
if chosen == Compositor::Hyprland {
|
|
||||||
let _ = std::process::Command::new("systemctl")
|
|
||||||
.args([
|
|
||||||
"--user",
|
|
||||||
"try-restart",
|
|
||||||
"xdg-desktop-portal-hyprland.service",
|
|
||||||
"xdg-desktop-portal.service",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
compositor = chosen.id(),
|
|
||||||
"settled desktop portal env for the switched-to session"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn settle_desktop_portal(_chosen: Compositor) {}
|
|
||||||
|
|
||||||
/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`],
|
|
||||||
/// written into the env knobs `GamescopeDisplay::create` dispatches on.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum GamescopeMode {
|
|
||||||
/// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode.
|
|
||||||
Managed,
|
|
||||||
/// Attach to an already-running gamescope (capture + inject, no lifecycle ownership).
|
|
||||||
Attach,
|
|
||||||
/// Bare-spawn a headless gamescope per session, nesting the session's launch command.
|
|
||||||
Spawn,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters):
|
|
||||||
/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an
|
|
||||||
/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box
|
|
||||||
/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code
|
|
||||||
/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session);
|
|
||||||
/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final
|
|
||||||
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn pick_gamescope_mode(
|
|
||||||
dedicated_launch: bool,
|
|
||||||
force_managed: bool,
|
|
||||||
attach_env: bool,
|
|
||||||
node_env: bool,
|
|
||||||
session_env: bool,
|
|
||||||
managed_infra: bool,
|
|
||||||
foreign_gamescope: bool,
|
|
||||||
) -> GamescopeMode {
|
|
||||||
if force_managed {
|
|
||||||
GamescopeMode::Managed
|
|
||||||
} else if attach_env || node_env {
|
|
||||||
GamescopeMode::Attach
|
|
||||||
} else if dedicated_launch {
|
|
||||||
// A dedicated game session always spawns its own headless gamescope at the client's mode,
|
|
||||||
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
|
|
||||||
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
|
|
||||||
GamescopeMode::Spawn
|
|
||||||
} else if session_env || managed_infra {
|
|
||||||
GamescopeMode::Managed
|
|
||||||
} else if foreign_gamescope {
|
|
||||||
GamescopeMode::Attach
|
|
||||||
} else {
|
|
||||||
GamescopeMode::Spawn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
|
|
||||||
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
|
|
||||||
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
|
|
||||||
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
|
|
||||||
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
|
|
||||||
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
|
|
||||||
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
|
|
||||||
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
|
||||||
/// forces managed over all of it.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
|
|
||||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
let backend = match chosen {
|
|
||||||
Compositor::Gamescope => "gamescope",
|
|
||||||
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
|
|
||||||
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
|
|
||||||
Compositor::Kwin => "kwin",
|
|
||||||
// GNOME has neither fake_input nor the wlr protocols → RemoteDesktop portal via libei.
|
|
||||||
Compositor::Mutter => "libei",
|
|
||||||
// Hyprland kept `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` (D4) — same wlr
|
|
||||||
// injector as sway/river, no code change.
|
|
||||||
Compositor::Wlroots | Compositor::Hyprland => "wlr",
|
|
||||||
};
|
|
||||||
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
|
||||||
if chosen == Compositor::Gamescope {
|
|
||||||
let mode = pick_gamescope_mode(
|
|
||||||
dedicated_launch,
|
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(),
|
|
||||||
gamescope::managed_session_available(),
|
|
||||||
gamescope::foreign_gamescope_running(),
|
|
||||||
);
|
|
||||||
tracing::info!(?mode, "gamescope sub-mode");
|
|
||||||
match mode {
|
|
||||||
GamescopeMode::Attach => {
|
|
||||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
|
||||||
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
|
|
||||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GamescopeMode::Managed => {
|
|
||||||
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
|
|
||||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
|
|
||||||
}
|
|
||||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
|
||||||
}
|
|
||||||
GamescopeMode::Spawn => {
|
|
||||||
// Bare spawn: `create` must fall through to the spawn path, so neither knob may
|
|
||||||
// linger from an earlier connect's managed/attach selection.
|
|
||||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
|
||||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
|
|
||||||
|
|
||||||
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
|
|
||||||
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
|
|
||||||
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
|
|
||||||
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
|
|
||||||
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
|
|
||||||
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
|
||||||
use policy::GameSession;
|
|
||||||
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
if gamescope::is_available() {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
false // Windows: a launching session opens into the one desktop (no gamescope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
|
||||||
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
|
||||||
/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope
|
|
||||||
/// sub-mode (the env knobs are that resolution's output).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn launch_is_nested(compositor: Compositor) -> bool {
|
|
||||||
compositor == Compositor::Gamescope
|
|
||||||
&& with_env_lock(|| {
|
|
||||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
|
|
||||||
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Launch `cmd` into the live gamescope session (managed/attach — see
|
|
||||||
/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private
|
|
||||||
/// backend module.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
|
||||||
gamescope::launch_into_session(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
|
||||||
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
|
||||||
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
|
||||||
/// exit (review #4/#8). Always `false` off Linux.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn dedicated_game_exited(node_id: u32) -> bool {
|
|
||||||
gamescope::game_session_exited(node_id)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn cancel_pending_tv_restore() {
|
|
||||||
gamescope::cancel_pending_restore();
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn cancel_pending_tv_restore() {}
|
|
||||||
|
|
||||||
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
||||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||||
@@ -953,48 +289,6 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET`
|
|
||||||
/// (gamescope's EIS server) for the input injector. Under `$XDG_RUNTIME_DIR` (per-user 0700).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn gamescope_ei_socket_file() -> std::path::PathBuf {
|
|
||||||
gamescope::ei_socket_file()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
|
||||||
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
|
|
||||||
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
|
||||||
/// (which reuses the warm session, avoiding the per-connect gamescope stop/relaunch that leaked GPU
|
|
||||||
/// context on F44). No-op on other compositors / when nothing was taken. Needs [`start_restore_worker`]
|
|
||||||
/// running to actually fire.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn restore_managed_session() {
|
|
||||||
gamescope::schedule_restore_tv_session();
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn restore_managed_session() {}
|
|
||||||
|
|
||||||
/// Start the host-lifetime worker that fires debounced [`restore_managed_session`] restores once a
|
|
||||||
/// client has been gone long enough. Hold the returned handle for the host's lifetime; dropping it
|
|
||||||
/// stops the worker. Call once from `serve()`.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
|
||||||
gamescope::start_restore_worker()
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
|
||||||
std::sync::Arc::new(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recover a stranded TV takeover from a crashed previous host instance
|
|
||||||
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
|
|
||||||
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn restore_takeover_on_startup() {
|
|
||||||
gamescope::restore_takeover_on_startup();
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
pub fn restore_takeover_on_startup() {}
|
|
||||||
|
|
||||||
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
|
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
|
||||||
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
|
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
|
||||||
// it), so no cfg gate. See `design/display-management.md`.
|
// it), so no cfg gate. See `design/display-management.md`.
|
||||||
@@ -1066,31 +360,39 @@ pub fn effective_topology() -> policy::Topology {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/gamescope.rs"]
|
#[path = "vdisplay/linux/gamescope.rs"]
|
||||||
mod gamescope;
|
mod gamescope;
|
||||||
|
|
||||||
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
|
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
|
||||||
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
|
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
|
||||||
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
|
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[path = "vdisplay/identity.rs"]
|
#[path = "vdisplay/identity.rs"]
|
||||||
pub(crate) mod identity;
|
pub(crate) mod identity;
|
||||||
|
|
||||||
// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the
|
// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the
|
||||||
// live-session registry, wired into the punktfunk/1 handshake.
|
// live-session registry, wired into the punktfunk/1 handshake.
|
||||||
#[path = "vdisplay/admission.rs"]
|
#[path = "vdisplay/admission.rs"]
|
||||||
pub(crate) mod admission;
|
pub(crate) mod admission;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/hyprland.rs"]
|
#[path = "vdisplay/linux/hyprland.rs"]
|
||||||
mod hyprland;
|
mod hyprland;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/kwin.rs"]
|
#[path = "vdisplay/linux/kwin.rs"]
|
||||||
mod kwin;
|
mod kwin;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "vdisplay/windows/manager.rs"]
|
#[path = "vdisplay/windows/manager.rs"]
|
||||||
pub(crate) mod manager;
|
pub(crate) mod manager;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/mutter.rs"]
|
#[path = "vdisplay/linux/mutter.rs"]
|
||||||
mod mutter;
|
mod mutter;
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "vdisplay/windows/pf_vdisplay.rs"]
|
#[path = "vdisplay/windows/pf_vdisplay.rs"]
|
||||||
pub(crate) mod pf_vdisplay;
|
pub(crate) mod pf_vdisplay;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/wlroots.rs"]
|
#[path = "vdisplay/linux/wlroots.rs"]
|
||||||
mod wlroots;
|
mod wlroots;
|
||||||
@@ -1125,40 +427,6 @@ mod tests {
|
|||||||
assert_eq!(compositor_for_kind(ActiveKind::None), None);
|
assert_eq!(compositor_for_kind(ActiveKind::None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[test]
|
|
||||||
fn gamescope_mode_ladder() {
|
|
||||||
use GamescopeMode::*;
|
|
||||||
let pick = pick_gamescope_mode;
|
|
||||||
// (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
|
||||||
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
|
||||||
assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
|
|
||||||
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
|
||||||
assert_eq!(
|
|
||||||
pick(false, false, false, false, false, true, false),
|
|
||||||
Managed
|
|
||||||
);
|
|
||||||
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
|
|
||||||
// Foreign gamescope on an infra-less box: attach and mirror it.
|
|
||||||
assert_eq!(pick(false, false, false, false, false, false, true), Attach);
|
|
||||||
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
|
||||||
assert_eq!(
|
|
||||||
pick(false, false, false, false, true, false, false),
|
|
||||||
Managed
|
|
||||||
);
|
|
||||||
// Explicit attach/node wins over infra…
|
|
||||||
assert_eq!(pick(false, false, true, false, false, true, false), Attach);
|
|
||||||
assert_eq!(pick(false, false, false, true, true, true, false), Attach);
|
|
||||||
// …and force-managed wins over everything.
|
|
||||||
assert_eq!(pick(false, true, true, true, false, false, false), Managed);
|
|
||||||
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
|
|
||||||
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
|
|
||||||
// …but the explicit operator overrides still win over dedicated.
|
|
||||||
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
|
|
||||||
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
|
|
||||||
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_active_session_is_side_effect_free_and_terminates() {
|
fn detect_active_session_is_side_effect_free_and_terminates() {
|
||||||
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
|
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//! Gamescope-session routing (plan §W3 — carved out of [`super`]): mode selection
|
||||||
|
//! ([`pick_gamescope_mode`]), input-env routing ([`apply_input_env`]), dedicated-game-session
|
||||||
|
//! decisions/launch ([`wants_dedicated_game_session`], [`launch_into_gamescope_session`]), and the
|
||||||
|
//! managed-session restore workers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`],
|
||||||
|
/// written into the env knobs `GamescopeDisplay::create` dispatches on.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum GamescopeMode {
|
||||||
|
/// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode.
|
||||||
|
Managed,
|
||||||
|
/// Attach to an already-running gamescope (capture + inject, no lifecycle ownership).
|
||||||
|
Attach,
|
||||||
|
/// Bare-spawn a headless gamescope per session, nesting the session's launch command.
|
||||||
|
Spawn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters):
|
||||||
|
/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an
|
||||||
|
/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box
|
||||||
|
/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code
|
||||||
|
/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session);
|
||||||
|
/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final
|
||||||
|
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn pick_gamescope_mode(
|
||||||
|
dedicated_launch: bool,
|
||||||
|
force_managed: bool,
|
||||||
|
attach_env: bool,
|
||||||
|
node_env: bool,
|
||||||
|
session_env: bool,
|
||||||
|
managed_infra: bool,
|
||||||
|
foreign_gamescope: bool,
|
||||||
|
) -> GamescopeMode {
|
||||||
|
if force_managed {
|
||||||
|
GamescopeMode::Managed
|
||||||
|
} else if attach_env || node_env {
|
||||||
|
GamescopeMode::Attach
|
||||||
|
} else if dedicated_launch {
|
||||||
|
// A dedicated game session always spawns its own headless gamescope at the client's mode,
|
||||||
|
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
|
||||||
|
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
|
||||||
|
GamescopeMode::Spawn
|
||||||
|
} else if session_env || managed_infra {
|
||||||
|
GamescopeMode::Managed
|
||||||
|
} else if foreign_gamescope {
|
||||||
|
GamescopeMode::Attach
|
||||||
|
} else {
|
||||||
|
GamescopeMode::Spawn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
|
||||||
|
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
|
||||||
|
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
|
||||||
|
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
|
||||||
|
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
|
||||||
|
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
|
||||||
|
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
|
||||||
|
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
||||||
|
/// forces managed over all of it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
|
||||||
|
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let backend = match chosen {
|
||||||
|
Compositor::Gamescope => "gamescope",
|
||||||
|
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
|
||||||
|
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
|
||||||
|
Compositor::Kwin => "kwin",
|
||||||
|
// GNOME has neither fake_input nor the wlr protocols → RemoteDesktop portal via libei.
|
||||||
|
Compositor::Mutter => "libei",
|
||||||
|
// Hyprland kept `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` (D4) — same wlr
|
||||||
|
// injector as sway/river, no code change.
|
||||||
|
Compositor::Wlroots | Compositor::Hyprland => "wlr",
|
||||||
|
};
|
||||||
|
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
||||||
|
if chosen == Compositor::Gamescope {
|
||||||
|
let mode = pick_gamescope_mode(
|
||||||
|
dedicated_launch,
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(),
|
||||||
|
gamescope::managed_session_available(),
|
||||||
|
gamescope::foreign_gamescope_running(),
|
||||||
|
);
|
||||||
|
tracing::info!(?mode, "gamescope sub-mode");
|
||||||
|
match mode {
|
||||||
|
GamescopeMode::Attach => {
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||||
|
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
|
||||||
|
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GamescopeMode::Managed => {
|
||||||
|
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
|
||||||
|
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
|
||||||
|
}
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||||
|
}
|
||||||
|
GamescopeMode::Spawn => {
|
||||||
|
// Bare spawn: `create` must fall through to the spawn path, so neither knob may
|
||||||
|
// linger from an earlier connect's managed/attach selection.
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
|
||||||
|
|
||||||
|
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
|
||||||
|
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
|
||||||
|
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
|
||||||
|
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
|
||||||
|
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
|
||||||
|
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
||||||
|
use policy::GameSession;
|
||||||
|
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if gamescope::is_available() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
false // Windows: a launching session opens into the one desktop (no gamescope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
||||||
|
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
||||||
|
/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope
|
||||||
|
/// sub-mode (the env knobs are that resolution's output).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn launch_is_nested(compositor: Compositor) -> bool {
|
||||||
|
compositor == Compositor::Gamescope
|
||||||
|
&& with_env_lock(|| {
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
|
||||||
|
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch `cmd` into the live gamescope session (managed/attach — see
|
||||||
|
/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private
|
||||||
|
/// backend module.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
||||||
|
gamescope::launch_into_session(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
||||||
|
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
||||||
|
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
||||||
|
/// exit (review #4/#8). Always `false` off Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn dedicated_game_exited(node_id: u32) -> bool {
|
||||||
|
gamescope::game_session_exited(node_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn cancel_pending_tv_restore() {
|
||||||
|
gamescope::cancel_pending_restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn cancel_pending_tv_restore() {}
|
||||||
|
|
||||||
|
/// Path of the file where the gamescope backend relays the nested session's `LIBEI_SOCKET`
|
||||||
|
/// (gamescope's EIS server) for the input injector. Under `$XDG_RUNTIME_DIR` (per-user 0700).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn gamescope_ei_socket_file() -> std::path::PathBuf {
|
||||||
|
gamescope::ei_socket_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
||||||
|
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
|
||||||
|
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
||||||
|
/// (which reuses the warm session, avoiding the per-connect gamescope stop/relaunch that leaked GPU
|
||||||
|
/// context on F44). No-op on other compositors / when nothing was taken. Needs [`start_restore_worker`]
|
||||||
|
/// running to actually fire.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn restore_managed_session() {
|
||||||
|
gamescope::schedule_restore_tv_session();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn restore_managed_session() {}
|
||||||
|
|
||||||
|
/// Start the host-lifetime worker that fires debounced [`restore_managed_session`] restores once a
|
||||||
|
/// client has been gone long enough. Hold the returned handle for the host's lifetime; dropping it
|
||||||
|
/// stops the worker. Call once from `serve()`.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||||
|
gamescope::start_restore_worker()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||||
|
std::sync::Arc::new(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recover a stranded TV takeover from a crashed previous host instance
|
||||||
|
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
|
||||||
|
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn restore_takeover_on_startup() {
|
||||||
|
gamescope::restore_takeover_on_startup();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn restore_takeover_on_startup() {}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamescope_mode_ladder() {
|
||||||
|
use GamescopeMode::*;
|
||||||
|
let pick = pick_gamescope_mode;
|
||||||
|
// (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
||||||
|
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
||||||
|
assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
|
||||||
|
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
||||||
|
assert_eq!(
|
||||||
|
pick(false, false, false, false, false, true, false),
|
||||||
|
Managed
|
||||||
|
);
|
||||||
|
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
|
||||||
|
// Foreign gamescope on an infra-less box: attach and mirror it.
|
||||||
|
assert_eq!(pick(false, false, false, false, false, false, true), Attach);
|
||||||
|
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
||||||
|
assert_eq!(
|
||||||
|
pick(false, false, false, false, true, false, false),
|
||||||
|
Managed
|
||||||
|
);
|
||||||
|
// Explicit attach/node wins over infra…
|
||||||
|
assert_eq!(pick(false, false, true, false, false, true, false), Attach);
|
||||||
|
assert_eq!(pick(false, false, false, true, true, true, false), Attach);
|
||||||
|
// …and force-managed wins over everything.
|
||||||
|
assert_eq!(pick(false, true, true, true, false, false, false), Managed);
|
||||||
|
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
|
||||||
|
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
|
||||||
|
// …but the explicit operator overrides still win over dedicated.
|
||||||
|
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
|
||||||
|
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
|
||||||
|
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
//! Live graphical-session detection + session-epoch + process-env retargeting (plan §W3 — the
|
||||||
|
//! self-contained subsystem carved out of [`super`]). Detects the active compositor/session
|
||||||
|
//! ([`detect_active_session`]), tracks the session epoch so pooled displays never outlive their
|
||||||
|
//! compositor instance, and retargets the process env at the live session ([`apply_session_env`],
|
||||||
|
//! [`settle_desktop_portal`]) under `super::ENV_LOCK`.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
||||||
|
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
||||||
|
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
||||||
|
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
|
||||||
|
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
|
||||||
|
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
|
||||||
|
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
|
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
|
||||||
|
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
|
||||||
|
pub fn session_epoch() -> u64 {
|
||||||
|
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
|
||||||
|
/// instance (kind change, or same-kind new PID). Returns the new value.
|
||||||
|
pub fn bump_session_epoch() -> u64 {
|
||||||
|
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
|
||||||
|
/// genuine instance change from a stable re-detect.
|
||||||
|
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
|
||||||
|
std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
|
||||||
|
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
|
||||||
|
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
|
||||||
|
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
|
||||||
|
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
|
||||||
|
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
|
||||||
|
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
|
||||||
|
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
|
||||||
|
pub fn observe_session_instance(active: &ActiveSession) {
|
||||||
|
let cur = (active.kind, active.compositor_pid);
|
||||||
|
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(prev) = *last {
|
||||||
|
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
|
||||||
|
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
|
||||||
|
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
|
||||||
|
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
|
||||||
|
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
|
||||||
|
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
|
||||||
|
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
|
||||||
|
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
|
||||||
|
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
|
||||||
|
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
|
||||||
|
if is_desktop_kind(prev.0) {
|
||||||
|
if let Some(old) = compositor_for_kind(prev.0) {
|
||||||
|
registry::invalidate_backend(old.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let epoch = bump_session_epoch();
|
||||||
|
tracing::info!(
|
||||||
|
from = ?prev.0,
|
||||||
|
to = ?cur.0,
|
||||||
|
epoch,
|
||||||
|
"desktop compositor instance changed — session epoch bumped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*last = Some(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
||||||
|
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
||||||
|
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
||||||
|
fn is_desktop_kind(kind: ActiveKind) -> bool {
|
||||||
|
matches!(
|
||||||
|
kind,
|
||||||
|
ActiveKind::DesktopKde
|
||||||
|
| ActiveKind::DesktopGnome
|
||||||
|
| ActiveKind::DesktopWlroots
|
||||||
|
| ActiveKind::DesktopHyprland
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kind of graphical session live for our uid *right now* — the basis for per-connect backend
|
||||||
|
/// selection on a box that flips between Steam Gaming Mode and a KDE/GNOME desktop (Bazzite,
|
||||||
|
/// SteamOS). Detected by probing which compositor process is actually running, not by a static
|
||||||
|
/// env var, so the host follows the box as the user switches sessions.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ActiveKind {
|
||||||
|
/// A `gamescope` session is live (Steam Gaming Mode / `gamescope-session-plus`).
|
||||||
|
Gaming,
|
||||||
|
/// A KWin / Plasma desktop is live.
|
||||||
|
DesktopKde,
|
||||||
|
/// A GNOME / Mutter desktop is live.
|
||||||
|
DesktopGnome,
|
||||||
|
/// A wlroots-proper (Sway / River) desktop is live.
|
||||||
|
DesktopWlroots,
|
||||||
|
/// A Hyprland desktop is live (distinct from [`DesktopWlroots`](ActiveKind::DesktopWlroots):
|
||||||
|
/// its own `hyprctl` IPC + xdph portal, though it shares the wlr virtual-input path).
|
||||||
|
DesktopHyprland,
|
||||||
|
/// No recognized graphical session is running for our uid.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session environment that points a backend at the [detected](detect_active_session) active
|
||||||
|
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
|
||||||
|
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
|
||||||
|
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
|
||||||
|
/// per connect and every backend that reads them then opens against the live session.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct SessionEnv {
|
||||||
|
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
|
||||||
|
/// PipeWire-node / D-Bus driven and don't talk Wayland to us).
|
||||||
|
pub wayland_display: Option<String>,
|
||||||
|
/// `/run/user/<uid>` — the trustworthy anchor (the default PipeWire daemon + bus live here).
|
||||||
|
pub xdg_runtime_dir: String,
|
||||||
|
/// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=<runtime>/bus`).
|
||||||
|
pub dbus_session_bus_address: String,
|
||||||
|
/// `XDG_CURRENT_DESKTOP` to advertise (KDE/GNOME/sway/Hyprland/gamescope) — drives portal/EIS
|
||||||
|
/// routing (xdph keys its Hyprland-specific behavior off `Hyprland`).
|
||||||
|
pub xdg_current_desktop: Option<String>,
|
||||||
|
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
|
||||||
|
/// [`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
|
||||||
|
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor.
|
||||||
|
pub hyprland_signature: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
|
||||||
|
pub struct ActiveSession {
|
||||||
|
pub kind: ActiveKind,
|
||||||
|
pub env: SessionEnv,
|
||||||
|
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
|
||||||
|
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
|
||||||
|
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
|
||||||
|
pub compositor_pid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveSession {
|
||||||
|
/// A "nothing live" result carrying just the runtime-dir anchor.
|
||||||
|
fn none() -> ActiveSession {
|
||||||
|
ActiveSession {
|
||||||
|
kind: ActiveKind::None,
|
||||||
|
env: SessionEnv {
|
||||||
|
xdg_runtime_dir: default_runtime_dir(),
|
||||||
|
dbus_session_bus_address: default_bus(&default_runtime_dir()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
compositor_pid: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The concrete backend that drives a given live-session kind. `None` for [`ActiveKind::None`].
|
||||||
|
pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
|
||||||
|
match kind {
|
||||||
|
ActiveKind::Gaming => Some(Compositor::Gamescope),
|
||||||
|
ActiveKind::DesktopKde => Some(Compositor::Kwin),
|
||||||
|
ActiveKind::DesktopGnome => Some(Compositor::Mutter),
|
||||||
|
ActiveKind::DesktopWlroots => Some(Compositor::Wlroots),
|
||||||
|
ActiveKind::DesktopHyprland => Some(Compositor::Hyprland),
|
||||||
|
ActiveKind::None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn default_runtime_dir() -> String {
|
||||||
|
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
|
||||||
|
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
format!("/run/user/{uid}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn default_runtime_dir() -> String {
|
||||||
|
std::env::var("XDG_RUNTIME_DIR").unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_bus(runtime: &str) -> String {
|
||||||
|
std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap_or_else(|_| format!("unix:path={runtime}/bus"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the graphical session live for our uid right now (cheap, side-effect-free: a `/proc`
|
||||||
|
/// scan plus a runtime-dir socket scan — well under the handshake timeout). The authority is the
|
||||||
|
/// running compositor process; a desktop compositor outranks a lingering gamescope. Used to route
|
||||||
|
/// each connect to the correct backend, and to derive the [`SessionEnv`] that targets it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn detect_active_session() -> ActiveSession {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
|
||||||
|
// it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
let xdg_runtime_dir = default_runtime_dir();
|
||||||
|
let dbus = default_bus(&xdg_runtime_dir);
|
||||||
|
|
||||||
|
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
|
||||||
|
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
|
||||||
|
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
|
||||||
|
let mut kind = ActiveKind::None;
|
||||||
|
let mut best = 0u8;
|
||||||
|
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
|
||||||
|
// session epoch (A4), not just a kind change.
|
||||||
|
let mut winning_pid: Option<u32> = None;
|
||||||
|
if let Ok(entries) = std::fs::read_dir("/proc") {
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let name = e.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pid_path = e.path();
|
||||||
|
let Ok(md) = std::fs::metadata(&pid_path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (k, prio) = match comm.trim() {
|
||||||
|
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
|
||||||
|
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
|
||||||
|
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
|
||||||
|
// Hyprland is its own backend (hyprctl + xdph) — split it out of the sway/river
|
||||||
|
// wlroots-proper family (design/hyprland-support.md D1).
|
||||||
|
"Hyprland" | "hyprland" => (ActiveKind::DesktopHyprland, 4),
|
||||||
|
"sway" | "river" => (ActiveKind::DesktopWlroots, 4),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let pid = name.parse::<u32>().ok();
|
||||||
|
if prio > best {
|
||||||
|
best = prio;
|
||||||
|
kind = k;
|
||||||
|
winning_pid = pid;
|
||||||
|
} else if prio == best {
|
||||||
|
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
|
||||||
|
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
|
||||||
|
// `/proc` enumeration order — which `observe_session_instance` would misread as a
|
||||||
|
// compositor restart and tear a live display down (re-review low-severity note).
|
||||||
|
if let (Some(p), Some(w)) = (pid, winning_pid) {
|
||||||
|
if p < w {
|
||||||
|
kind = k;
|
||||||
|
winning_pid = Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wayland-protocol backends (KWin, wlroots, Hyprland) need the live socket for input (the wlr
|
||||||
|
// virtual pointer/keyboard client connects to it); Gaming-attach and Mutter are node/D-Bus
|
||||||
|
// driven and don't.
|
||||||
|
let wayland_display = match kind {
|
||||||
|
ActiveKind::DesktopKde | ActiveKind::DesktopWlroots | ActiveKind::DesktopHyprland => {
|
||||||
|
find_wayland_socket(&xdg_runtime_dir, uid)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let xdg_current_desktop = match kind {
|
||||||
|
ActiveKind::DesktopKde => Some("KDE".to_string()),
|
||||||
|
ActiveKind::DesktopGnome => Some("GNOME".to_string()),
|
||||||
|
ActiveKind::DesktopWlroots => Some("sway".to_string()),
|
||||||
|
// G4: advertise the real desktop so portal routing (portals.conf `[Hyprland]`) and xdph's
|
||||||
|
// own Hyprland checks work — NOT the old blanket `sway`.
|
||||||
|
ActiveKind::DesktopHyprland => Some("Hyprland".to_string()),
|
||||||
|
ActiveKind::Gaming => Some("gamescope".to_string()),
|
||||||
|
ActiveKind::None => None,
|
||||||
|
};
|
||||||
|
// Discover the Hyprland instance signature so `hyprctl` can reach the compositor even when the
|
||||||
|
// host runs as a systemd `--user` service that never inherited the session env.
|
||||||
|
let hyprland_signature = match kind {
|
||||||
|
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
ActiveSession {
|
||||||
|
kind,
|
||||||
|
env: SessionEnv {
|
||||||
|
wayland_display,
|
||||||
|
xdg_runtime_dir,
|
||||||
|
dbus_session_bus_address: dbus,
|
||||||
|
xdg_current_desktop,
|
||||||
|
hyprland_signature,
|
||||||
|
},
|
||||||
|
compositor_pid: winning_pid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the live Hyprland instance signature (`HYPRLAND_INSTANCE_SIGNATURE`) for our uid. Trust a
|
||||||
|
/// valid inherited value first (the host launched inside the session); otherwise pick the
|
||||||
|
/// newest-mtime instance directory under `$XDG_RUNTIME_DIR/hypr/` that we own and that still has a
|
||||||
|
/// live `.socket.sock` — the same "newest wins" heuristic as [`find_wayland_socket`]. A desktop
|
||||||
|
/// normally exposes exactly one. (Phase-2 refinement: match the instance to `compositor_pid` via
|
||||||
|
/// `hyprctl instances` when several coexist — `design/hyprland-support.md` §Phase-1.1.)
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let hypr = std::path::Path::new(runtime).join("hypr");
|
||||||
|
if let Ok(sig) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
||||||
|
if !sig.is_empty() && hypr.join(&sig).join(".socket.sock").exists() {
|
||||||
|
return Some(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||||
|
for e in std::fs::read_dir(&hypr).ok()?.flatten() {
|
||||||
|
let Ok(md) = e.metadata() else { continue };
|
||||||
|
if !md.is_dir() || md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !e.path().join(".socket.sock").exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = e.file_name().to_string_lossy().into_owned();
|
||||||
|
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||||
|
cands.push((mtime, name));
|
||||||
|
}
|
||||||
|
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
||||||
|
cands.into_iter().next().map(|(_, n)| n)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn detect_active_session() -> ActiveSession {
|
||||||
|
ActiveSession::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the live `wayland-*` socket in `runtime` for our uid (skipping `.lock` sidecars). Trust a
|
||||||
|
/// valid inherited `WAYLAND_DISPLAY` first; otherwise take the newest-mtime socket we own (a
|
||||||
|
/// desktop session normally exposes exactly one).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
if let Ok(w) = std::env::var("WAYLAND_DISPLAY") {
|
||||||
|
if !w.is_empty() {
|
||||||
|
let p = if w.starts_with('/') {
|
||||||
|
std::path::PathBuf::from(&w)
|
||||||
|
} else {
|
||||||
|
std::path::Path::new(runtime).join(&w)
|
||||||
|
};
|
||||||
|
if p.exists() {
|
||||||
|
return Some(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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("wayland-") || name.ends_with(".lock") {
|
||||||
|
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, name));
|
||||||
|
}
|
||||||
|
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
||||||
|
cands.into_iter().next().map(|(_, n)| n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
|
||||||
|
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
||||||
|
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
||||||
|
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
||||||
|
/// re-applies.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn apply_session_env(active: &ActiveSession) {
|
||||||
|
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let e = &active.env;
|
||||||
|
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||||
|
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||||
|
if let Some(w) = &e.wayland_display {
|
||||||
|
std::env::set_var("WAYLAND_DISPLAY", w);
|
||||||
|
}
|
||||||
|
if let Some(d) = &e.xdg_current_desktop {
|
||||||
|
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
||||||
|
}
|
||||||
|
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
|
||||||
|
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
|
||||||
|
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
|
||||||
|
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
|
||||||
|
match &e.hyprland_signature {
|
||||||
|
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||||
|
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||||
|
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
|
||||||
|
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
|
||||||
|
// graphical session" handshake error. Clear them so `available()` reports the truth and the
|
||||||
|
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
|
||||||
|
if active.kind == ActiveKind::None {
|
||||||
|
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||||
|
std::env::remove_var("WAYLAND_DISPLAY");
|
||||||
|
}
|
||||||
|
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||||
|
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||||
|
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||||
|
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
|
||||||
|
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn apply_session_env(_active: &ActiveSession) {}
|
||||||
|
|
||||||
|
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
|
||||||
|
/// connected while NO graphical session is live for this uid — the state a compositor crash
|
||||||
|
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
|
||||||
|
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
|
||||||
|
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
|
||||||
|
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
|
||||||
|
/// a recovery is underway (just launched, or launched within the debounce window), letting the
|
||||||
|
/// handshake error tell the client to simply retry.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn try_recover_session() -> bool {
|
||||||
|
let Some(cmd) = crate::config::config().recover_session_cmd.clone() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
|
||||||
|
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
|
||||||
|
return true; // a launch is already in flight — the retry lands in the recovered session
|
||||||
|
}
|
||||||
|
match std::process::Command::new("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&cmd)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(mut child) => {
|
||||||
|
*last = Some(std::time::Instant::now());
|
||||||
|
tracing::warn!(cmd = %cmd,
|
||||||
|
"no live graphical session — launched the operator's session-recovery command");
|
||||||
|
// Reap off-thread so the finished child never lingers as a zombie.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(cmd = %cmd, error = %e,
|
||||||
|
"session-recovery command failed to launch");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn try_recover_session() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
|
||||||
|
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
|
||||||
|
/// against a half-stale env — it accepts events but they don't reach the compositor until a
|
||||||
|
/// reconnect. Push the live session env into the systemd/D-Bus activation environment and (for KWin,
|
||||||
|
/// whose input rides the xdg RemoteDesktop portal) restart the portal so it re-reads it — the same
|
||||||
|
/// settling a fresh desktop login does. Best-effort; mirrors the wlroots portal restart. GNOME uses
|
||||||
|
/// Mutter's *direct* EIS (no xdg portal), so it only needs the env push.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn settle_desktop_portal(chosen: Compositor) {
|
||||||
|
const VARS: &[&str] = &[
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"XDG_CURRENT_DESKTOP",
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
];
|
||||||
|
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
|
||||||
|
// re-activated portal/backend inherits the live session.
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args(["--user", "import-environment"])
|
||||||
|
.args(VARS)
|
||||||
|
.status();
|
||||||
|
let _ = std::process::Command::new("dbus-update-activation-environment")
|
||||||
|
.arg("--systemd")
|
||||||
|
.args(VARS)
|
||||||
|
.status();
|
||||||
|
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
|
||||||
|
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
|
||||||
|
// the now-live session, then let it settle before the injector reopens against it.
|
||||||
|
if chosen == Compositor::Kwin {
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args([
|
||||||
|
"--user",
|
||||||
|
"try-restart",
|
||||||
|
"xdg-desktop-portal-kde.service",
|
||||||
|
"xdg-desktop-portal.service",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
|
}
|
||||||
|
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
|
||||||
|
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
|
||||||
|
// re-read the now-live session, mirroring the KWin settling above.
|
||||||
|
if chosen == Compositor::Hyprland {
|
||||||
|
let _ = std::process::Command::new("systemctl")
|
||||||
|
.args([
|
||||||
|
"--user",
|
||||||
|
"try-restart",
|
||||||
|
"xdg-desktop-portal-hyprland.service",
|
||||||
|
"xdg-desktop-portal.service",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
compositor = chosen.id(),
|
||||||
|
"settled desktop portal env for the switched-to session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn settle_desktop_portal(_chosen: Compositor) {}
|
||||||
Reference in New Issue
Block a user