fix(vdisplay): compositor availability follows the live session, not the env

GNOME/Mutter reported "Unavailable" on a host sitting in a live Mutter
session — and, on the same request, "Default", because the two columns had
different sources. available() asked each backend, and those probes read
the process env (XDG_CURRENT_DESKTOP for Mutter, WAYLAND_DISPLAY for
KWin's registry handshake, SWAYSOCK for sway) — env a host started outside
the session (systemd --user, a TTY, ssh) never inherited. It is only
retargeted at the live session on the connect path, so the answer also
flipped depending on whether anyone had connected yet.

Both columns now come from the same /proc scan detect() already used: the
live session's compositor is usable by definition, as is an explicit
operator pin, and the per-backend probe stays as the fallback for backends
that are not the live session (gamescope, which spawns its own). A live
KWin without the zkde_screencast grant now surfaces as available and fails
at create with that probe's precise message, which beats "no usable
compositor" on a box visibly running KDE.

Mutter's env sniff stays deliberately narrow — one var, not three.
XDG_CURRENT_DESKTOP is the one apply_session_env owns end to end (written
per connect, scrubbed when nothing is live); sniffing DESKTOP_SESSION
alongside would resurrect the bug that scrub exists to prevent, where a
stale value after a gnome-shell crash routes the next client into a dead
session.

Non-Linux hosts now report no compositors at all rather than five Linux
backends flagged unavailable with no default — on Windows the pf-vdisplay
driver is the only backend and vdisplay::open ignores the argument, so the
old list read as broken detection instead of "not applicable here". The
console says so explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit eb7ba3d6177552f5c3ed6a15439404084869c636)
This commit is contained in:
2026-07-28 17:01:59 +02:00
parent 3b11288c97
commit 0868b6a364
8 changed files with 137 additions and 68 deletions
+60 -33
View File
@@ -171,29 +171,49 @@ impl Compositor {
/// The compositor backends usable on this host *right now*: gamescope wherever its binary is /// 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 /// 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, /// session's own compositor (KWin / Mutter / wlroots / Hyprland) when the host runs inside it.
/// side-effect-free probes — safe to call per management request. A concrete client preference /// Cheap, side-effect-free probes — safe to call per management request. A concrete client
/// is validated against this set before it's honored (see the punktfunk/1 handshake's resolution). /// preference is validated against this set before it's honored (see the punktfunk/1 handshake's
/// resolution).
///
/// The **live session is the primary signal**, ahead of each backend's own probe. Those probes read
/// the process env (`XDG_CURRENT_DESKTOP` for Mutter, `WAYLAND_DISPLAY` for KWin's registry
/// handshake, `SWAYSOCK` for sway) — env a host started *outside* the session (a `systemd --user`
/// unit, a TTY, ssh) never inherited. It is only retargeted at the live session on the connect path
/// ([`apply_session_env`]), so enumerating before the first client connect reported "unavailable"
/// for the very desktop the operator was sitting in — while [`detect`], which scans `/proc`, marked
/// that same backend the default. The management API showed both badges on one row, and the answer
/// flipped depending on whether anyone had connected yet. Basing both on the same `/proc` scan makes
/// the two agree, and makes the answer independent of how the host was launched.
pub fn available() -> Vec<Compositor> { pub fn available() -> Vec<Compositor> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let mut v = Vec::new(); let live = compositor_for_kind(detect_active_session().kind);
if kwin::is_available() { // An explicit operator pin counts too: it's what `detect` returns as the default and what
v.push(Compositor::Kwin); // the host will actually drive, so listing it "unavailable" was the same contradiction.
} let pinned = pf_host_config::config()
if gamescope::is_available() { .compositor
v.push(Compositor::Gamescope); .as_deref()
} .and_then(compositor_from_pin);
if mutter::is_available() { Compositor::all()
v.push(Compositor::Mutter); .into_iter()
} .filter(|&c| {
if wlroots::is_available() { // Running (or pinned) ⇒ usable, without consulting the env-reading probe. KWin is
v.push(Compositor::Wlroots); // the one backend whose probe checks a real capability beyond "is it up" (the
} // privileged `zkde_screencast` grant); a live-but-ungranted KWin now surfaces as
if hyprland::is_available() { // available and fails at create with that probe's precise message, which beats
v.push(Compositor::Hyprland); // "no usable compositor" on a box that is visibly running KDE.
} live == Some(c)
v || pinned == Some(c)
|| match c {
Compositor::Kwin => kwin::is_available(),
Compositor::Gamescope => gamescope::is_available(),
Compositor::Mutter => mutter::is_available(),
Compositor::Wlroots => wlroots::is_available(),
Compositor::Hyprland => hyprland::is_available(),
}
})
.collect()
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
{ {
@@ -201,6 +221,21 @@ pub fn available() -> Vec<Compositor> {
} }
} }
/// The backend an explicit `PUNKTFUNK_COMPOSITOR` value names (aliases included), or `None` for an
/// unrecognized value. Shared by [`detect`] (which turns `None` into an error naming the accepted
/// values) and [`available`] (which just ignores a typo'd pin).
fn compositor_from_pin(v: &str) -> Option<Compositor> {
Some(match v.trim().to_ascii_lowercase().as_str() {
"kwin" | "kde" | "plasma" => Compositor::Kwin,
// `hyprland` names the distinct backend (D1); `wlroots`/`sway`/`wlr` stay wlroots-proper.
"hyprland" | "hypr" => Compositor::Hyprland,
"wlroots" | "sway" | "wlr" | "river" => Compositor::Wlroots,
"mutter" | "gnome" => Compositor::Mutter,
"gamescope" => Compositor::Gamescope,
_ => return None,
})
}
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var` /// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With /// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
/// the default concurrent native sessions each running `resolve_compositor` in its own /// the default concurrent native sessions each running `resolve_compositor` in its own
@@ -223,19 +258,11 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
pub fn detect() -> Result<Compositor> { pub fn detect() -> Result<Compositor> {
if let Some(v) = pf_host_config::config().compositor.as_deref() { if let Some(v) = pf_host_config::config().compositor.as_deref() {
return match v.trim().to_ascii_lowercase().as_str() { return compositor_from_pin(v).ok_or_else(|| {
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin), anyhow::anyhow!(
// `hyprland` names the distinct backend (D1); `wlroots`/`sway`/`wlr` stay wlroots-proper. "unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)"
"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")] #[cfg(target_os = "linux")]
if let Some(c) = compositor_for_kind(detect_active_session().kind) { if let Some(c) = compositor_for_kind(detect_active_session().kind) {
@@ -105,8 +105,17 @@ impl MutterDisplay {
} }
/// Mutter is usable when the host runs inside a GNOME session (its `RecordVirtual` D-Bus API /// Mutter is usable when the host runs inside a GNOME session (its `RecordVirtual` D-Bus API
/// drives the *live* compositor). Cheap signal: `XDG_CURRENT_DESKTOP` names GNOME — same basis /// drives the *live* compositor). Cheap env signal, avoiding a blocking D-Bus round-trip on the
/// as [`super::detect`], avoiding a blocking D-Bus round-trip on the enumeration path. /// enumeration path.
///
/// This is the *fallback* answer only: [`crate::available`] treats a running `gnome-shell` (the
/// `/proc` scan) as the authority, because this var belongs to the session and a host launched
/// outside it — `systemd --user`, a TTY, ssh — never inherited it. Deliberately still just the ONE
/// var: `XDG_CURRENT_DESKTOP` is the one [`crate::apply_session_env`] owns end to end (it writes it
/// per connect and *scrubs* it when nothing is live), so sniffing `DESKTOP_SESSION` /
/// `XDG_SESSION_DESKTOP` alongside would resurrect the bug that scrub exists to prevent — a stale
/// `gnome` there after a gnome-shell crash reports Mutter usable and routes the next client into a
/// dead session (45 s create timeouts instead of a crisp handshake error).
pub fn is_available() -> bool { pub fn is_available() -> bool {
std::env::var("XDG_CURRENT_DESKTOP") std::env::var("XDG_CURRENT_DESKTOP")
.map(|d| d.to_ascii_uppercase().contains("GNOME")) .map(|d| d.to_ascii_uppercase().contains("GNOME"))
+16 -5
View File
@@ -319,9 +319,19 @@ pub(crate) struct AvailableCompositor {
) )
)] )]
pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> { pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
let available = crate::vdisplay::available(); // Compositor backends are a Linux concept. On Windows the pf-vdisplay IddCx driver is the sole
let default = crate::vdisplay::detect().ok(); // virtual-display backend and `vdisplay::open` ignores the compositor argument entirely, so the
Json( // list could only ever be five Linux backends flagged unavailable with no default — which reads
// as broken detection rather than "not applicable here". Report none and let the console say so.
#[cfg(not(target_os = "linux"))]
let list = Vec::new();
#[cfg(target_os = "linux")]
// One `/proc` scan backs BOTH columns (see `vdisplay::available`), so a backend can no longer
// be the auto-detect default and "unavailable" on the same row — the contradiction that made
// this list look broken on a box plainly running the compositor it called unavailable.
let list = {
let available = crate::vdisplay::available();
let default = crate::vdisplay::detect().ok();
crate::vdisplay::Compositor::all() crate::vdisplay::Compositor::all()
.into_iter() .into_iter()
.map(|c| AvailableCompositor { .map(|c| AvailableCompositor {
@@ -330,8 +340,9 @@ pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
available: available.contains(&c), available: available.contains(&c),
default: default == Some(c), default: default == Some(c),
}) })
.collect(), .collect()
) };
Json(list)
} }
/// Live host status /// Live host status
+9 -2
View File
@@ -652,9 +652,16 @@ async fn compositors_lists_all_backends_with_flags() {
let (status, body) = send(&app, get_req("/api/v1/compositors")).await; let (status, body) = send(&app, get_req("/api/v1/compositors")).await;
assert_eq!(status, StatusCode::OK); assert_eq!(status, StatusCode::OK);
let arr = body.as_array().expect("array"); let arr = body.as_array().expect("array");
// Compositor backends are Linux-only; elsewhere the list is empty on purpose (the console
// renders "not applicable on this host" instead of five greyed-out rows).
#[cfg(not(target_os = "linux"))]
assert!(arr.is_empty(), "non-Linux hosts advertise no compositors");
// Every backend the host knows, in stable order. // Every backend the host knows, in stable order.
let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect(); #[cfg(target_os = "linux")]
assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]); {
let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]);
}
for c in arr { for c in arr {
assert!(c["available"].is_boolean()); assert!(c["available"].is_boolean());
assert!(c["default"].is_boolean()); assert!(c["default"].is_boolean());
+1
View File
@@ -46,6 +46,7 @@
"compositor_available": "Verfügbar", "compositor_available": "Verfügbar",
"compositor_unavailable": "Nicht verfügbar", "compositor_unavailable": "Nicht verfügbar",
"compositor_default": "Standard", "compositor_default": "Standard",
"compositor_none": "Dieser Host hat keine Compositor-Backends die gibt es nur unter Linux. Virtuelle Displays laufen hier über den mitgelieferten pf-vdisplay-Treiber.",
"host_gpus": "GPUs", "host_gpus": "GPUs",
"host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.", "host_gpus_help": "Die GPU, auf der der Host aufnimmt und encodiert. Automatisch wählt die beste GPU; eine bevorzugte GPU bindet Aufnahme + Encoding an sie. Eine Änderung gilt ab der nächsten Sitzung.",
"gpu_automatic": "Automatisch", "gpu_automatic": "Automatisch",
+1
View File
@@ -46,6 +46,7 @@
"compositor_available": "Available", "compositor_available": "Available",
"compositor_unavailable": "Unavailable", "compositor_unavailable": "Unavailable",
"compositor_default": "Default", "compositor_default": "Default",
"compositor_none": "This host has no compositor backends — they are a Linux concept. Virtual displays here run on the bundled pf-vdisplay driver.",
"host_gpus": "GPUs", "host_gpus": "GPUs",
"host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.", "host_gpus_help": "The GPU the host captures and encodes on. Automatic picks the best GPU; preferring one pins capture + encode to it. A change applies to the next session.",
"gpu_automatic": "Automatic", "gpu_automatic": "Automatic",
+34 -26
View File
@@ -94,33 +94,41 @@ export const HostView: FC<{
error={compositors.error} error={compositors.error}
refetch={compositors.refetch} refetch={compositors.refetch}
> >
<ul className="divide-y rounded-md border"> {/* Empty is a real answer, not a load failure: a Windows host drives the
{compositors.data?.map((c) => ( pf-vdisplay driver and has no compositor backends at all. */}
<li {compositors.data?.length === 0 ? (
key={c.id} <p className="rounded-md border p-4 text-sm text-muted-foreground">
className="flex items-center justify-between gap-4 px-4 py-3" {m.compositor_none()}
> </p>
<div className="min-w-0"> ) : (
<div className="flex items-center gap-2"> <ul className="divide-y rounded-md border">
<span className="font-medium">{c.label}</span> {compositors.data?.map((c) => (
{c.default && ( <li
<Badge variant="secondary"> key={c.id}
{m.compositor_default()} className="flex items-center justify-between gap-4 px-4 py-3"
</Badge> >
)} <div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{c.label}</span>
{c.default && (
<Badge variant="secondary">
{m.compositor_default()}
</Badge>
)}
</div>
<code className="text-xs text-muted-foreground">
{c.id}
</code>
</div> </div>
<code className="text-xs text-muted-foreground"> <Badge variant={c.available ? "default" : "outline"}>
{c.id} {c.available
</code> ? m.compositor_available()
</div> : m.compositor_unavailable()}
<Badge variant={c.available ? "default" : "outline"}> </Badge>
{c.available </li>
? m.compositor_available() ))}
: m.compositor_unavailable()} </ul>
</Badge> )}
</li>
))}
</ul>
</QueryState> </QueryState>
</CardContent> </CardContent>
</Card> </Card>
+5
View File
@@ -16,6 +16,11 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {}; export const Default: Story = {};
/** A non-Linux host: compositor backends don't exist there, so the list is empty by design. */
export const NoCompositors: Story = {
args: { compositors: { data: [], isLoading: false, error: null } },
};
export const Loading: Story = { export const Loading: Story = {
args: { args: {
host: { data: undefined, isLoading: true, error: null }, host: { data: undefined, isLoading: true, error: null },