Files
punktfunk/crates/pf-vdisplay/src/lib.rs
T
enricobuehler b89dbfa979
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m12s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 32s
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 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m57s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m51s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
apple / screenshots (push) Successful in 6m13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
deb / build-publish (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-host (push) Successful in 9m29s
windows-host / package (push) Successful in 16m24s
arch / build-publish (push) Successful in 17m40s
android / android (push) Successful in 18m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
ci / rust (push) Successful in 26m16s
feat(gamescope): end dedicated stream on Steam game exit + auto --steam
Dedicated single-game sessions now end cleanly when the launched game
quits, and Steam launches get gamescope's Steam integration without an
operator env knob.

End-on-exit: the existing APP_EXITED close only fired when gamescope's
PipeWire node vanished, which never happens for Steam — the nested
`steam` is the resident singleton client and stays up after a game
quits, so gamescope (and its node) never die and the stream sat on a
hidden Steam session forever. Add a Steam AppId reaper watcher
(pf-vdisplay .../gamescope/discovery.rs): steam_appid_from_launch()
parses steam://rungameid/<id>; wait_for_steam_game_exit() waits for the
game to start (<=300s grace) then exit (3s confirm); steam_game_running()
scans same-uid /proc for Steam's launch reaper matching both the
`SteamLaunch` and `AppId=<id>` argv tokens (exact-match; reaper lifetime
== game lifetime, so shader precompile can't false-trigger). The host
spawns a pf1-gamewatch thread for nested Steam launches that closes the
connection with APP_EXITED (launcher clients return to their library)
and sets quit/stop; cancelled via stop if the session ends first.
Non-Steam nested launches keep the node-death path (gamescope's child
IS the game).

Flags: auto-enable `--steam` whenever the launch is a Steam launch (was
only the global PUNKTFUNK_GAMESCOPE_STEAM knob, default-off) — in-game
overlay / Steam+X / gamepad-UI nav for Steam titles with no operator
config. New opt-in PUNKTFUNK_GAMESCOPE_GRAB_CURSOR adds
`--force-grab-cursor` for a real game launch (FPS mouselook); default
OFF because it forces relative mode, which breaks absolute-pointer
games/menus.

Verified: fmt clean; clippy -D warnings clean on the three crates;
pf-vdisplay 64/0 (incl. new steam-appid parse test); punktfunk-host
builds + 186/0; reaper /proc detection smoke-tested (detect, exact
non-match, gone-after-exit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:24:34 +02:00

483 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Virtual display orchestration (plan §6 / §W6) — the project's differentiator.
//!
//! A [`VirtualDisplay`] creates a *client-sized* output on demand, rendered natively and
//! headless (no scaling), to be captured and streamed, then torn down on disconnect. There is
//! no cross-compositor Wayland protocol for this, so each compositor has its own backend behind
//! this trait:
//!
//! * **KWin** — privileged `zkde_screencast_unstable_v1::stream_virtual_output` ([`kwin`]).
//! * **wlroots/Sway** — `swaymsg create_output` + `output mode --custom` ([`wlroots`]).
//! * **Mutter/GNOME** — D-Bus `RemoteDesktop` + `ScreenCast.RecordVirtual` ([`mutter`]).
//!
//! [`VirtualDisplay::create`] returns a [`VirtualOutput`]: the PipeWire node to capture plus an
//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture
//! consumes the node via the host `capture::capture_virtual_output`.
// Scaffold: some backend paths + Stage-3 identity are defined ahead of the target that uses them.
#![allow(dead_code)]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::Result;
pub use punktfunk_core::Mode;
#[cfg(target_os = "linux")]
use std::os::fd::OwnedFd;
/// A display-lifecycle event the registry emits when it creates or releases a managed virtual
/// display. The host wires [`DISPLAY_EVENT_SINK`] to translate these into its SSE event bus
/// (`crate::events` in the orchestrator), so this crate emits lifecycle signals without owning the
/// bus type — the one reach into the orchestrator's event module, inverted to a leaf hook (plan §W6).
pub enum DisplayEvent {
/// A virtual display was created on `backend` at `width`×`height`@`refresh_hz`.
Created {
backend: String,
width: u32,
height: u32,
refresh_hz: u32,
},
/// `count` managed displays were released.
Released { count: u32 },
}
/// The host-registered sink that forwards [`DisplayEvent`]s to the SSE bus. Set once at startup; a
/// display event before it is set is silently dropped (no subscriber yet).
pub static DISPLAY_EVENT_SINK: std::sync::OnceLock<Box<dyn Fn(DisplayEvent) + Send + Sync>> =
std::sync::OnceLock::new();
/// Emit a [`DisplayEvent`] to the host sink, if registered.
pub(crate) fn emit_display_event(ev: DisplayEvent) {
if let Some(sink) = DISPLAY_EVENT_SINK.get() {
sink(ev);
}
}
/// The virtual-display backend contract — [`DisplayOwnership`], [`VirtualOutput`], and the
/// [`VirtualDisplay`] trait (plan §W3). Re-exported so `crate::VirtualDisplay` etc. stay
/// stable for the ~30 external call sites.
#[path = "vdisplay/backend.rs"]
pub(crate) mod backend;
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
/// 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};
/// 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, launch_into_gamescope_session,
launch_is_nested, steam_appid_from_launch, watch_steam_game_exit,
};
/// Compositors punktfunk knows how to drive (plan §6).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Compositor {
/// KWin / Plasma 6 — `zkde_screencast` virtual output.
Kwin,
/// wlroots proper (Sway / River) — headless `swaymsg create_output`.
Wlroots,
/// Mutter / GNOME — headless backend + Mutter DBus `RecordVirtual`.
Mutter,
/// gamescope — spawned headless at the client's size/refresh; capture its PipeWire node.
Gamescope,
/// Hyprland — headless `hyprctl output create` + the xdg-desktop-portal-hyprland (xdph)
/// ScreenCast portal. A distinct backend from [`Wlroots`](Compositor::Wlroots): Hyprland
/// dropped wlroots in v0.42 but kept the client-facing wlr protocols, so it shares the wlr
/// virtual-input path yet needs its own IPC (`hyprctl`) and portal (xdph) — see
/// `design/hyprland-support.md`.
Hyprland,
}
impl Compositor {
/// Stable lowercase id used on the wire / management API (matches
/// [`punktfunk_core::CompositorPref::as_str`]).
pub fn id(self) -> &'static str {
match self {
Compositor::Kwin => "kwin",
Compositor::Wlroots => "wlroots",
Compositor::Mutter => "mutter",
Compositor::Gamescope => "gamescope",
Compositor::Hyprland => "hyprland",
}
}
/// Human label for UIs.
pub fn label(self) -> &'static str {
match self {
Compositor::Kwin => "KWin / KDE Plasma",
Compositor::Wlroots => "wlroots (Sway / River)",
Compositor::Mutter => "Mutter / GNOME",
Compositor::Gamescope => "gamescope",
Compositor::Hyprland => "Hyprland",
}
}
/// The protocol [`punktfunk_core::CompositorPref`] naming this backend.
pub fn as_pref(self) -> punktfunk_core::CompositorPref {
use punktfunk_core::CompositorPref as P;
match self {
Compositor::Kwin => P::Kwin,
Compositor::Wlroots => P::Wlroots,
Compositor::Mutter => P::Mutter,
Compositor::Gamescope => P::Gamescope,
// D2: no distinct wire byte for Hyprland — it shares the wlroots-family `Wlroots` pref.
// A client asking for `wlroots`/`hyprland` gets whichever of the two is the live session
// (`pick_compositor` (host `native`) resolves the family).
Compositor::Hyprland => P::Wlroots,
}
}
/// The concrete backend a [`punktfunk_core::CompositorPref`] names, or `None` for `Auto`.
pub fn from_pref(p: punktfunk_core::CompositorPref) -> Option<Compositor> {
use punktfunk_core::CompositorPref as P;
Some(match p {
P::Auto => return None,
P::Kwin => Compositor::Kwin,
P::Wlroots => Compositor::Wlroots,
P::Mutter => Compositor::Mutter,
P::Gamescope => Compositor::Gamescope,
})
}
/// Every backend, in a stable display order (for enumeration / UIs).
pub fn all() -> [Compositor; 5] {
[
Compositor::Kwin,
Compositor::Gamescope,
Compositor::Mutter,
Compositor::Wlroots,
Compositor::Hyprland,
]
}
}
/// The compositor backends usable on this host *right now*: gamescope wherever its binary is
/// installed (it spawns a nested session — independent of the running desktop), plus the live
/// session's own compositor (KWin / Mutter / wlroots) when the host runs inside it. Cheap,
/// side-effect-free probes — safe to call per management request. A concrete client preference
/// is validated against this set before it's honored (see the punktfunk/1 handshake's resolution).
pub fn available() -> Vec<Compositor> {
#[cfg(target_os = "linux")]
{
let mut v = Vec::new();
if kwin::is_available() {
v.push(Compositor::Kwin);
}
if gamescope::is_available() {
v.push(Compositor::Gamescope);
}
if mutter::is_available() {
v.push(Compositor::Mutter);
}
if wlroots::is_available() {
v.push(Compositor::Wlroots);
}
if hyprland::is_available() {
v.push(Compositor::Hyprland);
}
v
}
#[cfg(not(target_os = "linux"))]
{
Vec::new()
}
}
/// 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
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
/// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is
/// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never
/// rides the process env at all — the remaining knobs here (session retarget, gamescope sub-mode)
/// still carry a cross-session *value* confusion window inherent to a process-global env.
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
f()
}
/// 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.
pub fn detect() -> Result<Compositor> {
if let Some(v) = pf_host_config::config().compositor.as_deref() {
return match v.trim().to_ascii_lowercase().as_str() {
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
// `hyprland` names the distinct backend (D1); `wlroots`/`sway`/`wlr` stay wlroots-proper.
"hyprland" | "hypr" => Ok(Compositor::Hyprland),
"wlroots" | "sway" | "wlr" | "river" => Ok(Compositor::Wlroots),
"mutter" | "gnome" => Ok(Compositor::Mutter),
"gamescope" => Ok(Compositor::Gamescope),
other => {
anyhow::bail!(
"unknown PUNKTFUNK_COMPOSITOR '{other}' (kwin|wlroots|hyprland|mutter|gamescope)"
)
}
};
}
#[cfg(target_os = "linux")]
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
}
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_uppercase();
if desktop.contains("KDE") {
Ok(Compositor::Kwin)
} else if desktop.contains("GNOME") {
Ok(Compositor::Mutter)
} else if desktop.contains("HYPRLAND") {
Ok(Compositor::Hyprland)
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
Ok(Compositor::Wlroots)
} else {
anyhow::bail!(
"could not detect compositor: no live graphical session for this uid and \
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
)
}
}
/// Open the virtual-display driver for `compositor`.
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")]
{
match compositor {
Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)),
Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)),
Compositor::Mutter => Ok(Box::new(mutter::MutterDisplay::new()?)),
Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)),
Compositor::Hyprland => Ok(Box::new(hyprland::HyprlandDisplay::new()?)),
}
}
#[cfg(target_os = "windows")]
{
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
let _ = compositor;
// `ensure_available` self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
// devnode present, interface gone): one device cycle + re-probe before giving up.
anyhow::ensure!(
driver::ensure_available(),
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
not loaded (the host installer bundles it; reinstall or check the driver state)"
);
Ok(Box::new(driver::PfVdisplayDisplay::new()?))
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
let _ = compositor;
anyhow::bail!("virtual displays require Linux or Windows")
}
}
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right
/// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate
/// on actual readiness instead of racing the compositor with a blind sleep.
///
/// KWin gets a real check (the privileged `zkde_screencast` global must be advertised). The
/// others are spawn/D-Bus/portal-based and have no equivalent pre-flight global, so a probe
/// just confirms the backend opens — `Ok(())` means "go ahead and try `create`".
pub fn probe(compositor: Compositor) -> Result<()> {
#[cfg(target_os = "linux")]
{
match compositor {
Compositor::Kwin => kwin::probe(),
// Hyprland gets a real pre-flight: `hyprctl` must reach the compositor (else a clear
// error instead of a create-time failure), plus the permission-system warning.
Compositor::Hyprland => hyprland::probe(),
// gamescope spawns its own nested session per `create`; Mutter is D-Bus on demand;
// wlroots creates the output on demand — nothing to pre-check beyond "Linux".
Compositor::Gamescope | Compositor::Mutter | Compositor::Wlroots => Ok(()),
}
}
#[cfg(target_os = "windows")]
{
let _ = compositor;
driver::probe()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
let _ = compositor;
anyhow::bail!("virtual displays require Linux or Windows")
}
}
// 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`.
#[path = "vdisplay/policy.rs"]
pub mod policy;
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
// property-tested; the registry executes the side effects its transitions dictate.
#[path = "vdisplay/lifecycle.rs"]
pub(crate) mod lifecycle;
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
// later), for the management API's /display/state + /display/release.
#[path = "vdisplay/registry.rs"]
pub mod registry;
// The pure display-arrangement engine (auto-row / manual → per-member positions), platform-neutral
// and unit-tested; the registry (state readout) and the KWin position apply consume it.
#[path = "vdisplay/layout.rs"]
pub(crate) mod layout;
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
/// Linux desktop path, where "stream this desktop" means promoting the virtual output to sole).
pub fn resolve_topology(t: policy::Topology) -> policy::Topology {
match t {
policy::Topology::Auto => {
if pf_host_config::config().compositor.is_some() {
policy::Topology::Extend
} else {
policy::Topology::Exclusive
}
}
concrete => concrete,
}
}
/// The concrete display topology for the current session — what the per-compositor backends (and the
/// Windows isolate gate) apply at create time. Precedence, mirroring the rest of the policy surface:
/// the **console policy** when configured, else the legacy **`PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`**
/// env (an operator's explicit choice — `1`→exclusive, `0`→extend), else the **Auto** default
/// ([`resolve_topology`]: exclusive on the auto-detected desktop / Windows, extend under a compositor
/// pin). Always resolved (never [`policy::Topology::Auto`]). This is the Stage-2 replacement for the
/// `apply_session_env` boolean write — the backends read policy directly, so the `primary` level
/// (distinct from `exclusive`) becomes expressible and one process-env mutation drops off the connect
/// path.
pub fn effective_topology() -> policy::Topology {
if let Some(e) = policy::prefs().configured_effective() {
return resolve_topology(e.topology);
}
// Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at
// most one of these is set), else the Auto default.
let legacy = [
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
]
.iter()
.find_map(|k| std::env::var(k).ok());
match legacy.as_deref().map(str::trim) {
Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive,
Some("0" | "false" | "no" | "off") => policy::Topology::Extend,
_ => resolve_topology(policy::Topology::Auto),
}
}
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::*` module names flat.
#[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 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 mod manager;
// DDC/CI panel power control (physical monitors), used only by the Windows manager to blank/wake the
// box's real panels around a virtual-display session — moved in with the subsystem (plan §W6).
#[cfg(target_os = "windows")]
#[path = "vdisplay/ddc.rs"]
mod ddc;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/mutter.rs"]
mod mutter;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/pf_vdisplay.rs"]
pub mod driver;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/wlroots.rs"]
mod wlroots;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn active_kind_maps_to_its_backend() {
assert_eq!(
compositor_for_kind(ActiveKind::Gaming),
Some(Compositor::Gamescope)
);
assert_eq!(
compositor_for_kind(ActiveKind::DesktopKde),
Some(Compositor::Kwin)
);
assert_eq!(
compositor_for_kind(ActiveKind::DesktopGnome),
Some(Compositor::Mutter)
);
assert_eq!(
compositor_for_kind(ActiveKind::DesktopWlroots),
Some(Compositor::Wlroots)
);
assert_eq!(
compositor_for_kind(ActiveKind::DesktopHyprland),
Some(Compositor::Hyprland)
);
// No live session → no backend; the caller turns this into a handshake error / fallback.
assert_eq!(compositor_for_kind(ActiveKind::None), None);
}
#[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
// any box (CI has no graphical session → ActiveKind::None, with the runtime-dir anchor).
let a = detect_active_session();
// The runtime-dir anchor is a Linux (XDG) concept; Windows has no equivalent.
#[cfg(target_os = "linux")]
assert!(!a.env.xdg_runtime_dir.is_empty());
// Wayland sockets are only resolved for the Wayland-protocol desktops.
if matches!(
a.kind,
ActiveKind::Gaming | ActiveKind::DesktopGnome | ActiveKind::None
) {
assert!(a.env.wayland_display.is_none());
}
}
}