diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 90390129..ac292a00 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -171,29 +171,49 @@ impl Compositor { /// 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). +/// session's own compositor (KWin / Mutter / wlroots / Hyprland) 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). +/// +/// 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 { #[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 + let live = compositor_for_kind(detect_active_session().kind); + // An explicit operator pin counts too: it's what `detect` returns as the default and what + // the host will actually drive, so listing it "unavailable" was the same contradiction. + let pinned = pf_host_config::config() + .compositor + .as_deref() + .and_then(compositor_from_pin); + Compositor::all() + .into_iter() + .filter(|&c| { + // Running (or pinned) ⇒ usable, without consulting the env-reading probe. KWin is + // 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 + // available and fails at create with that probe's precise message, which beats + // "no usable compositor" on a box that is visibly running KDE. + live == Some(c) + || 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"))] { @@ -201,6 +221,21 @@ pub fn available() -> Vec { } } +/// 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 { + 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` /// 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 @@ -223,19 +258,11 @@ pub fn with_env_lock(f: impl FnOnce() -> R) -> R { /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. pub fn detect() -> Result { 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)" - ) - } - }; + return compositor_from_pin(v).ok_or_else(|| { + anyhow::anyhow!( + "unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)" + ) + }); } #[cfg(target_os = "linux")] if let Some(c) = compositor_for_kind(detect_active_session().kind) { diff --git a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs index 3cb6866e..1a0f5194 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs @@ -105,8 +105,17 @@ impl MutterDisplay { } /// 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 -/// as [`super::detect`], avoiding a blocking D-Bus round-trip on the enumeration path. +/// drives the *live* compositor). Cheap env signal, avoiding a blocking D-Bus round-trip on the +/// 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 { std::env::var("XDG_CURRENT_DESKTOP") .map(|d| d.to_ascii_uppercase().contains("GNOME")) diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index 5e3e9792..4af1dbe9 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -319,9 +319,19 @@ pub(crate) struct AvailableCompositor { ) )] pub(crate) async fn list_compositors() -> Json> { - let available = crate::vdisplay::available(); - let default = crate::vdisplay::detect().ok(); - Json( + // Compositor backends are a Linux concept. On Windows the pf-vdisplay IddCx driver is the sole + // virtual-display backend and `vdisplay::open` ignores the compositor argument entirely, so the + // 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() .into_iter() .map(|c| AvailableCompositor { @@ -330,8 +340,9 @@ pub(crate) async fn list_compositors() -> Json> { available: available.contains(&c), default: default == Some(c), }) - .collect(), - ) + .collect() + }; + Json(list) } /// Live host status diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index c605f83c..05c0f79f 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -652,9 +652,16 @@ async fn compositors_lists_all_backends_with_flags() { let (status, body) = send(&app, get_req("/api/v1/compositors")).await; assert_eq!(status, StatusCode::OK); 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. - let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]); + #[cfg(target_os = "linux")] + { + 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 { assert!(c["available"].is_boolean()); assert!(c["default"].is_boolean()); diff --git a/web/messages/de.json b/web/messages/de.json index 591f4168..a0e4ce11 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -46,6 +46,7 @@ "compositor_available": "Verfügbar", "compositor_unavailable": "Nicht verfügbar", "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_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", diff --git a/web/messages/en.json b/web/messages/en.json index 11450235..f747a2e5 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -46,6 +46,7 @@ "compositor_available": "Available", "compositor_unavailable": "Unavailable", "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_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", diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index 487ce418..91e297bc 100644 --- a/web/src/sections/Host/view.tsx +++ b/web/src/sections/Host/view.tsx @@ -94,33 +94,41 @@ export const HostView: FC<{ error={compositors.error} refetch={compositors.refetch} > -
    - {compositors.data?.map((c) => ( -
  • -
    -
    - {c.label} - {c.default && ( - - {m.compositor_default()} - - )} + {/* Empty is a real answer, not a load failure: a Windows host drives the + pf-vdisplay driver and has no compositor backends at all. */} + {compositors.data?.length === 0 ? ( +

    + {m.compositor_none()} +

    + ) : ( +
      + {compositors.data?.map((c) => ( +
    • +
      +
      + {c.label} + {c.default && ( + + {m.compositor_default()} + + )} +
      + + {c.id} +
      - - {c.id} - -
    - - {c.available - ? m.compositor_available() - : m.compositor_unavailable()} - -
  • - ))} -
+ + {c.available + ? m.compositor_available() + : m.compositor_unavailable()} + + + ))} + + )} diff --git a/web/src/stories/Host.stories.tsx b/web/src/stories/Host.stories.tsx index 2a19527d..f8bc0650 100644 --- a/web/src/stories/Host.stories.tsx +++ b/web/src/stories/Host.stories.tsx @@ -16,6 +16,11 @@ type Story = StoryObj; 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 = { args: { host: { data: undefined, isLoading: true, error: null },