diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index fe95914c..2acf39ae 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -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)>> = - 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 { } } -/// 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, - /// `/run/user/` — the trustworthy anchor (the default PipeWire daemon + bus live here). - pub xdg_runtime_dir: String, - /// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=/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, - /// `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, -} - -/// 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, -} - -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 { - 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 = 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::().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 { - 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 { - 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(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> = 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 { - 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 diff --git a/crates/punktfunk-host/src/vdisplay/routing.rs b/crates/punktfunk-host/src/vdisplay/routing.rs new file mode 100644 index 00000000..b89a0b9c --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/routing.rs @@ -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 { + 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); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/session.rs b/crates/punktfunk-host/src/vdisplay/session.rs new file mode 100644 index 00000000..8ded7d74 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/session.rs @@ -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)>> = + 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, + /// `/run/user/` — the trustworthy anchor (the default PipeWire daemon + bus live here). + pub xdg_runtime_dir: String, + /// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=/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, + /// `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, +} + +/// 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, +} + +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 { + 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 = 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::().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 { + 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 { + 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> = 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) {}