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 use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||
|
||||
/// 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);
|
||||
/// Live-session detection + session-epoch + env retargeting (plan §W3).
|
||||
#[path = "vdisplay/session.rs"]
|
||||
pub(crate) mod session;
|
||||
pub use session::{
|
||||
apply_session_env, compositor_for_kind, detect_active_session, observe_session_instance,
|
||||
settle_desktop_portal, ActiveKind, ActiveSession, SessionEnv,
|
||||
};
|
||||
#[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
|
||||
/// 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
|
||||
)
|
||||
}
|
||||
/// Gamescope-session routing (plan §W3).
|
||||
#[path = "vdisplay/routing.rs"]
|
||||
pub(crate) mod routing;
|
||||
pub use routing::{
|
||||
apply_input_env, restore_managed_session, restore_takeover_on_startup, start_restore_worker,
|
||||
wants_dedicated_game_session,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use routing::{
|
||||
cancel_pending_tv_restore, dedicated_game_exited, gamescope_ei_socket_file,
|
||||
launch_into_gamescope_session, launch_is_nested,
|
||||
};
|
||||
|
||||
/// Compositors punktfunk knows how to drive (plan §6).
|
||||
#[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`
|
||||
/// 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
|
||||
@@ -514,338 +182,6 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
|
||||
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
|
||||
/// 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.
|
||||
@@ -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),
|
||||
// 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`.
|
||||
@@ -1066,31 +360,39 @@ pub fn effective_topology() -> policy::Topology {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/gamescope.rs"]
|
||||
mod gamescope;
|
||||
|
||||
// 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
|
||||
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
|
||||
#[allow(dead_code)]
|
||||
#[path = "vdisplay/identity.rs"]
|
||||
pub(crate) mod identity;
|
||||
|
||||
// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the
|
||||
// live-session registry, wired into the punktfunk/1 handshake.
|
||||
#[path = "vdisplay/admission.rs"]
|
||||
pub(crate) mod admission;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin.rs"]
|
||||
mod kwin;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub(crate) mod manager;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/mutter.rs"]
|
||||
mod mutter;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/pf_vdisplay.rs"]
|
||||
pub(crate) mod pf_vdisplay;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/wlroots.rs"]
|
||||
mod wlroots;
|
||||
@@ -1125,40 +427,6 @@ mod tests {
|
||||
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]
|
||||
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
|
||||
|
||||
@@ -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