diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index d50c89d4..0f734a47 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -214,6 +214,15 @@ pub struct HostConfig { /// showing the wrong monitor is worse than showing none). Linux-only today; see /// `design/per-monitor-portal-capture.md`. pub capture_monitor: Option, + /// `PUNKTFUNK_PORTAL_CURSOR_MODE` — `auto` (default) · `hidden` · `embedded` · `metadata`. + /// Pin the ScreenCast cursor mode the Linux portal backends PREFER, instead of the one the + /// session negotiates (`metadata` when the client draws the pointer itself, `embedded` + /// otherwise). The pin is a preference, not a command: it still runs through + /// `portal_cursor::pick`, so it can never ask a backend for a mode the backend does not + /// advertise — that closes the session rather than degrading, which is the failure this knob + /// sits next to. Exists for the backend that advertises a mode it implements badly, where + /// negotiation has nothing to go on; `embedded` is the safe answer there. + pub portal_cursor_mode: Option, /// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected /// session — this one is a constant operator knob; `apply_session_env` never writes it. pub compositor: Option, @@ -401,6 +410,12 @@ impl HostConfig { capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR") .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()), + // Same emptied-to-None rule: a bare `PUNKTFUNK_PORTAL_CURSOR_MODE=` left in a host.env + // means "not set", not an unrecognised value to warn about. The spellings are parsed + // (and warned about) at the use site, `pf-vdisplay`'s `portal_cursor::want`. + portal_cursor_mode: val("PUNKTFUNK_PORTAL_CURSOR_MODE") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), compositor: val("PUNKTFUNK_COMPOSITOR"), gamepad: val("PUNKTFUNK_GAMEPAD"), vdisplay: val("PUNKTFUNK_VDISPLAY"), diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 11779ab4..1b39dc32 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -824,6 +824,15 @@ pub mod admission; #[path = "vdisplay/linux/portal_config.rs"] mod portal_config; +/// Which ScreenCast cursor mode to REQUEST — negotiated against `AvailableCursorModes` instead of +/// hardcoded, because a mode the backend does not advertise closes the session outright. +/// +/// Declared unconditionally for the same reason as `portal_config` above: the ladder is pure +/// integer work whose tests are the only place its behaviour is observable without a compositor, +/// so they should run on every platform's CI rather than only where the callers compile. +#[path = "vdisplay/linux/portal_cursor.rs"] +mod portal_cursor; + #[cfg(target_os = "linux")] #[path = "vdisplay/linux/hyprland.rs"] mod hyprland; diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index d0f847a3..5f3d70bf 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -115,12 +115,21 @@ fn output_owner_pid(name: &str) -> Option { /// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one /// named headless output and spins up a portal thread owning the cast on it. pub struct HyprlandDisplay { - /// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal + /// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal /// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the - /// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the - /// pointer into frames, zero host-side cursor work (the pre-channel default this backend - /// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the - /// channel isn't silently dead here; KWin/Mutter are the validated legs). + /// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints + /// the pointer into frames, zero host-side cursor work (the pre-channel default this backend + /// always had). + /// + /// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdph actually + /// advertises, because requesting an unadvertised mode makes xdg-desktop-portal fail the call. + /// This used to be asserted instead, which is exactly how a cursor-forward session here became + /// a black client. + /// + /// ⚠️ On current xdph the metadata arm is UNREACHABLE, not merely untested: measured on .21 + /// 2026-08-14 (Hyprland 0.56.2, xdph 1.4.1) `AvailableCursorModes` = 3 — `Hidden|Embedded` + /// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter + /// remain the legs where the metadata channel is actually exercised. hw_cursor: bool, } @@ -788,13 +797,7 @@ fn portal_thread( stop: Arc, hw_cursor: bool, ) { - // Portal cursor mode per the session's channel negotiation (see the struct doc). - let cursor_mode = if hw_cursor { - CursorMode::Metadata - } else { - CursorMode::Embedded - }; - use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType}; + use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType}; use ashpd::desktop::PersistMode; use ashpd::enumflags2::BitFlags; @@ -818,6 +821,14 @@ fn portal_thread( let proxy = Screencast::new().await.context( "connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)", )?; + // NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a + // cursor mode the backend does not offer does not degrade — xdg-desktop-portal's + // FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it. + // MEASURED on .21 2026-08-14, Hyprland 0.56.2 + xdph 1.4.1 (both current): + // `AvailableCursorModes` = 3 (Hidden|Embedded) — metadata is NOT offered. So the old + // hardcode killed EVERY cursor-forward session here, on today's packages, not just on + // old installs: `unavailable cursor mode 4`, "pipeline build failed", black client. + let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await; let session = proxy .create_session(Default::default()) .await diff --git a/crates/pf-vdisplay/src/vdisplay/linux/portal_cursor.rs b/crates/pf-vdisplay/src/vdisplay/linux/portal_cursor.rs new file mode 100644 index 00000000..65dde2b6 --- /dev/null +++ b/crates/pf-vdisplay/src/vdisplay/linux/portal_cursor.rs @@ -0,0 +1,376 @@ +//! Which ScreenCast cursor mode to ASK the portal for — negotiated against what the backend +//! advertises, rather than asserted. +//! +//! The portal spec is unforgiving here: `SelectSources` with a cursor mode that is absent from +//! `AvailableCursorModes` does not quietly degrade — **xdg-desktop-portal itself rejects the call** +//! (`"Unavailable cursor mode %x"`, an `INVALID_ARGUMENT` from the FRONTEND, which validates the +//! request against the backend's advertised bitfield before the backend ever sees it). Both +//! wlr-family backends used to hardcode `Metadata` whenever the session had negotiated the cursor +//! channel, so every cursor-forward session died at `select_sources` — `unavailable cursor mode 4` +//! (4 being `Metadata`'s bit) and a client left on a black screen behind "pipeline build failed". +//! Field report 2026-08-14. +//! +//! ⚠️ This is NOT a stale-portal problem, and not Hyprland-specific. MEASURED on .21 2026-08-14 on +//! fully current packages — Hyprland **0.56.2**, xdg-desktop-portal-hyprland **1.4.1**, +//! xdg-desktop-portal **1.22.1** — with a live session and xdph attached (`[screencopy] init +//! successful`): `AvailableCursorModes` reads **3** (`Hidden|Embedded`) on both the backend impl +//! interface and the frontend. **Metadata is simply not offered by xdph today.** xdpw is the same +//! story from the other end: its `screencast.c` refuses `METADATA` outright. So the hardcode broke +//! every cursor-forward session on the entire wlr family, on current software — not only on old +//! installs. (xdph 1.4.1 would itself fall back — its binary carries +//! `"[screencopy] unsupported cursor_mode {}, fallback to {}"` — but it never gets the chance, +//! because the frontend fails the call first.) +//! +//! `pf-capture`'s own portal path has always negotiated (`portal::choose_cursor_mode`) — this is +//! that ladder, restated in the crate that owns the virtual-display backends. pf-vdisplay must not +//! depend on pf-capture (see this crate's Cargo.toml: "never on capture/inject or the +//! orchestrator"), so the two copies are deliberate; keep the ladders in step. +//! +//! Declared unconditionally although only the Linux backends call it: the ladder is pure integer +//! work, and its tests are the whole point of the module — this is a decision that leaves no trace +//! anyone can check without a compositor in front of them — so they run on every platform's CI +//! rather than on the one leg that compiles `mod hyprland`. + +/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints +/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + /// No pointer in the cast at all. + Hidden = 1, + /// The compositor paints the pointer into the frames it hands us. + Embedded = 2, + /// The pointer rides `SPA_META_Cursor` metadata beside the frames: the compositor keeps its + /// cheap hardware cursor plane, and the consumer either composites the shape itself or + /// forwards it to a client that draws its own. + Metadata = 4, +} + +impl Mode { + /// The portal's bit for this mode. + pub(crate) const fn bit(self) -> u32 { + self as u32 + } + + /// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`. + pub(crate) const fn name(self) -> &'static str { + match self { + Mode::Hidden => "hidden", + Mode::Embedded => "embedded", + Mode::Metadata => "metadata", + } + } + + /// What to ask for instead, best first, when this mode is not advertised. + const fn fallbacks(self) -> [Mode; 2] { + match self { + // The session wanted out-of-band shapes and cannot have them. `Embedded` still puts a + // pointer on the client's screen (the compositor's, burnt in) — and because no + // `SPA_META_Cursor` then arrives, the host feeds the cursor channel nothing and a + // cursor-forward client draws nothing of its own, so this is one pointer, not two. + // `Hidden` is last: it streams a desktop nobody can point at. + Mode::Metadata => [Mode::Embedded, Mode::Hidden], + // Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture path + // composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer. + Mode::Embedded => [Mode::Metadata, Mode::Hidden], + // A deliberate request for no pointer that the backend will not honour. Either + // remaining mode shows one; prefer the cheap burnt-in pointer over metadata nothing on + // this path is set up to draw. + Mode::Hidden => [Mode::Embedded, Mode::Metadata], + } + } +} + +/// The outcome of the ladder: what to request, and what the session actually wanted if those +/// differ (the caller logs the gap — a silently downgraded cursor is how this class of bug hides). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Choice { + /// The mode to put in `SelectSources`. Advertised, unless the backend advertised nothing. + pub(crate) mode: Mode, + /// Set only when `mode` is a downgrade: the mode the session asked for and could not have. + pub(crate) wanted: Option, +} + +/// Pick the cursor mode to request, given the backend's `AvailableCursorModes` bitfield. +/// +/// Never returns a mode outside `advertised` unless `advertised` names none we know — see the tail +/// comment, which is the one case with no right answer. +pub(crate) fn pick(advertised: u32, want: Mode) -> Choice { + if advertised & want.bit() != 0 { + return Choice { + mode: want, + wanted: None, + }; + } + for alt in want.fallbacks() { + if advertised & alt.bit() != 0 { + return Choice { + mode: alt, + wanted: Some(want), + }; + } + } + // The backend advertised no mode this build knows — 0, or only bits from a spec revision newer + // than us. Every request is then a coin flip against a session-closing rejection; `Hidden` is + // both the most universally implemented and the only one that cannot end up drawing two + // pointers. The caller warns: whatever this backend is doing, we are guessing. + Choice { + mode: Mode::Hidden, + wanted: Some(want), + } +} + +/// A parsed `PUNKTFUNK_PORTAL_CURSOR_MODE`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Pin { + /// Unset or `auto` — the session's own negotiation decides. + Auto, + /// Prefer this mode instead of what the session negotiated. Still runs the ladder, so a pin + /// can never re-create the session-killing request this module exists to prevent. + Mode(Mode), + /// Set to something we do not recognise. Treated as `Auto`, but the caller says so out loud — + /// a typo'd escape hatch that silently does nothing is worse than no escape hatch. + Unrecognised, +} + +/// Parse the `PUNKTFUNK_PORTAL_CURSOR_MODE` value. +pub(crate) fn parse_pin(raw: &str) -> Pin { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "auto" => Pin::Auto, + "hidden" | "none" => Pin::Mode(Mode::Hidden), + "embedded" | "composited" => Pin::Mode(Mode::Embedded), + "metadata" | "meta" => Pin::Mode(Mode::Metadata), + _ => Pin::Unrecognised, + } +} + +/// The mode this session wants before the backend gets a say: `Metadata` when the cursor channel +/// was negotiated (`set_hw_cursor` — the client draws the pointer, so the compositor must not burn +/// it in), `Embedded` otherwise. `PUNKTFUNK_PORTAL_CURSOR_MODE` overrides both. +/// +/// `backend` names the portal implementation for the log line only (`xdph`, `xdpw`). +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode { + let negotiated = if hw_cursor { + Mode::Metadata + } else { + Mode::Embedded + }; + let raw = match pf_host_config::config().portal_cursor_mode.as_deref() { + Some(raw) => raw, + None => return negotiated, + }; + match parse_pin(raw) { + Pin::Auto => negotiated, + Pin::Mode(pinned) => { + tracing::info!( + backend, + pinned = pinned.name(), + negotiated = negotiated.name(), + "ScreenCast: cursor mode pinned by PUNKTFUNK_PORTAL_CURSOR_MODE" + ); + pinned + } + Pin::Unrecognised => { + tracing::warn!( + backend, + value = raw, + negotiated = negotiated.name(), + "ScreenCast: unrecognised PUNKTFUNK_PORTAL_CURSOR_MODE (want auto|hidden|embedded|\ + metadata) — ignoring" + ); + negotiated + } + } +} + +#[cfg(target_os = "linux")] +impl Mode { + fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode { + use ashpd::desktop::screencast::CursorMode; + match self { + Mode::Hidden => CursorMode::Hidden, + Mode::Embedded => CursorMode::Embedded, + Mode::Metadata => CursorMode::Metadata, + } + } +} + +/// Ask the portal what it supports, run the ladder, and hand back the mode to put in +/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`, +/// the mode that predates the property and that every implementation has always had. +#[cfg(target_os = "linux")] +pub(crate) async fn negotiate( + proxy: &ashpd::desktop::screencast::Screencast, + hw_cursor: bool, + backend: &str, +) -> ashpd::desktop::screencast::CursorMode { + let want = want(hw_cursor, backend); + let advertised = match proxy.available_cursor_modes().await { + Ok(avail) => avail.bits(), + Err(e) => { + // `AvailableCursorModes` is a versioned property (ScreenCast v2); a portal too old to + // publish it is also too old to have metadata, and `Embedded` is what this backend + // requested for its whole life before the cursor channel existed. + tracing::warn!( + backend, + error = %e, + "ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor" + ); + return Mode::Embedded.to_ashpd(); + } + }; + let choice = pick(advertised, want); + match choice.wanted { + None => tracing::info!( + backend, + advertised = format_args!("{advertised:#05b}"), + mode = choice.mode.name(), + "ScreenCast: cursor mode negotiated" + ), + // The downgrade path — and the one that used to be a dead session. Loud, because a stream + // whose pointer quietly changed hands is exactly what nobody thinks to check. + Some(wanted) => tracing::warn!( + backend, + advertised = format_args!("{advertised:#05b}"), + wanted = wanted.name(), + mode = choice.mode.name(), + "ScreenCast: requested cursor mode is not advertised by this portal — downgrading \ + (requesting it anyway would close the session)" + ), + } + choice.mode.to_ashpd() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The portal's wire values. These are ABI — a backend rejecting our request prints the + /// number, and `4` is the one in the field report that started this module. + #[test] + fn mode_bits_are_the_portal_wire_values() { + assert_eq!(Mode::Hidden.bit(), 1); + assert_eq!(Mode::Embedded.bit(), 2); + assert_eq!(Mode::Metadata.bit(), 4); + } + + /// Our `Mode` is a restatement of ashpd's `CursorMode`, whose bits enumflags2 assigns from + /// declaration order — so a reordering upstream would silently repoint every mode. Pin it + /// where ashpd is actually compiled. + #[cfg(target_os = "linux")] + #[test] + fn mode_bits_match_ashpd() { + use ashpd::desktop::screencast::CursorMode; + use ashpd::enumflags2::BitFlags; + for m in [Mode::Hidden, Mode::Embedded, Mode::Metadata] { + assert_eq!( + BitFlags::from_flag(m.to_ashpd()).bits(), + m.bit(), + "{} drifted from ashpd", + m.name() + ); + } + assert_eq!(BitFlags::from_flag(CursorMode::Metadata).bits(), 4); + } + + /// THE REGRESSION, with the real number: `3` is what xdph actually advertises — measured on + /// .21 2026-08-14 against a live Hyprland 0.56.2 + xdph 1.4.1, both current. A cursor-forward + /// session wants metadata; asking for it made xdg-desktop-portal fail the call, and the client + /// got a black screen behind "pipeline build failed" / "unavailable cursor mode 4". + #[test] + fn metadata_wanted_but_unadvertised_downgrades_to_embedded() { + // Exactly the bitfield the portal reported on glass. + assert_eq!(Mode::Hidden.bit() | Mode::Embedded.bit(), 3); + let c = pick(3, Mode::Metadata); + assert_eq!(c.mode, Mode::Embedded); + assert_eq!(c.wanted, Some(Mode::Metadata)); + } + + /// The same portal, a session with no cursor channel: already asking for what exists, so the + /// fix must not perturb it. + #[test] + fn embedded_wanted_and_advertised_is_untouched() { + let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Embedded); + assert_eq!(c.mode, Mode::Embedded); + assert_eq!(c.wanted, None); + } + + /// A portal that does support metadata (KWin, Mutter, xdph ≥ #366) still gets it — the point + /// is to stop asserting, not to stop using it. + #[test] + fn metadata_is_used_where_advertised() { + let all = Mode::Hidden.bit() | Mode::Embedded.bit() | Mode::Metadata.bit(); + let c = pick(all, Mode::Metadata); + assert_eq!(c.mode, Mode::Metadata); + assert_eq!(c.wanted, None); + } + + /// Embedded wanted, only metadata offered: the CPU capture path composites it, so a pointer + /// survives. (Mirrors `pf-capture`'s ladder.) + #[test] + fn embedded_unadvertised_falls_to_metadata_not_hidden() { + let c = pick(Mode::Hidden.bit() | Mode::Metadata.bit(), Mode::Embedded); + assert_eq!(c.mode, Mode::Metadata); + assert_eq!(c.wanted, Some(Mode::Embedded)); + } + + /// A backend offering only `Hidden`: a cursorless stream beats a closed session. + #[test] + fn hidden_only_backend_yields_hidden() { + let c = pick(Mode::Hidden.bit(), Mode::Metadata); + assert_eq!(c.mode, Mode::Hidden); + assert_eq!(c.wanted, Some(Mode::Metadata)); + } + + /// Advertises nothing we know — no right answer, but it must still be a legal enum and flagged + /// as a downgrade so the warn fires. + #[test] + fn unknown_advertisement_guesses_hidden_and_reports_a_downgrade() { + for advertised in [0, 0b1000_0000] { + let c = pick(advertised, Mode::Metadata); + assert_eq!(c.mode, Mode::Hidden); + assert_eq!(c.wanted, Some(Mode::Metadata)); + } + } + + /// Whatever the ladder returns must be a mode the backend named — the invariant the old + /// hardcode broke. Exhaustive over every advertisement × every want. + #[test] + fn never_requests_an_unadvertised_mode() { + let modes = [Mode::Hidden, Mode::Embedded, Mode::Metadata]; + for advertised in 1u32..=0b111 { + for want in modes { + let c = pick(advertised, want); + assert!( + advertised & c.mode.bit() != 0, + "picked {} from advertised {advertised:#05b} (want {})", + c.mode.name(), + want.name() + ); + // A downgrade is reported exactly when one happened. + assert_eq!(c.wanted.is_some(), c.mode != want); + } + } + } + + #[test] + fn pin_parses_the_spellings_we_document() { + assert_eq!(parse_pin(""), Pin::Auto); + assert_eq!(parse_pin("auto"), Pin::Auto); + assert_eq!(parse_pin(" AUTO "), Pin::Auto); + assert_eq!(parse_pin("embedded"), Pin::Mode(Mode::Embedded)); + assert_eq!(parse_pin("Embedded"), Pin::Mode(Mode::Embedded)); + assert_eq!(parse_pin("metadata"), Pin::Mode(Mode::Metadata)); + assert_eq!(parse_pin("hidden"), Pin::Mode(Mode::Hidden)); + assert_eq!(parse_pin("2"), Pin::Unrecognised); + assert_eq!(parse_pin("yes"), Pin::Unrecognised); + } + + /// The hatch pins a PREFERENCE, not the request: pinning metadata at a portal without it must + /// still come out embedded rather than re-closing the session. + #[test] + fn a_pin_still_runs_the_ladder() { + let c = pick(Mode::Hidden.bit() | Mode::Embedded.bit(), Mode::Metadata); + assert_eq!(c.mode, Mode::Embedded); + } +} diff --git a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs index 7f37d307..4e6b341f 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs @@ -55,12 +55,17 @@ fn chooser_cmd() -> String { /// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) /// adds one headless output and spins up a portal thread owning the cast on it. pub struct WlrootsDisplay { - /// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal + /// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): PREFER portal /// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the - /// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the - /// pointer into frames, zero host-side cursor work (the pre-channel default this backend - /// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the - /// channel isn't silently dead here; KWin/Mutter are the validated legs). + /// composite blend. Off (every non-channel session): prefer `Embedded` — the compositor paints + /// the pointer into frames, zero host-side cursor work (the pre-channel default this backend + /// always had). + /// + /// Both are only a PREFERENCE: [`crate::portal_cursor`] settles it against what xdpw actually + /// advertises, because requesting an unadvertised mode closes the session outright. xdpw + /// refuses metadata by construction (see the portal thread), so on this backend the channel can + /// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to + /// cancel the cast and hand the client a black screen. hw_cursor: bool, } @@ -512,13 +517,7 @@ fn portal_thread( stop: Arc, hw_cursor: bool, ) { - // Portal cursor mode per the session's channel negotiation (see the struct doc). - let cursor_mode = if hw_cursor { - CursorMode::Metadata - } else { - CursorMode::Embedded - }; - use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType}; + use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType}; use ashpd::desktop::PersistMode; use ashpd::enumflags2::BitFlags; @@ -542,6 +541,14 @@ fn portal_thread( let proxy = Screencast::new().await.context( "connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)", )?; + // NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see + // the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its + // screencast.c refuses the mode outright — + // if (sess->screencast_data.cursor_mode & METADATA) { + // logprint(ERROR, "dbus: unsupported cursor mode requested, cancelling"); + // — so EVERY cursor-forward session on this backend asked for a mode that cancelled the + // cast. Different wording from xdph's "unavailable cursor mode 4", same dead session. + let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await; let session = proxy .create_session(Default::default()) .await diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 265ed867..c050bf72 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -115,6 +115,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t |---|---|---| | `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. | | `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. | +| `PUNKTFUNK_PORTAL_CURSOR_MODE` | `auto` *(default)* · `embedded` · `metadata` · `hidden` | **Hyprland / wlroots only, and a troubleshooting knob** — which ScreenCast cursor mode the host asks the portal for. Unset, the host asks for `metadata` when the client draws the pointer itself and `embedded` otherwise, then settles that against the modes your portal advertises; it never requests one your portal lacks. Set `embedded` if the pointer misbehaves on a portal that *claims* metadata support but implements it poorly — that is the one case the automatic negotiation cannot detect. A pin is still only a preference: it is checked against the advertised modes like any other. | ## Session recovery (Linux) diff --git a/docs-site/content/docs/hyprland.md b/docs-site/content/docs/hyprland.md index a05f07ea..a4a786a6 100644 --- a/docs-site/content/docs/hyprland.md +++ b/docs-site/content/docs/hyprland.md @@ -84,6 +84,31 @@ and fails the session with a clear error rather than streaming a blank surface. capture the Hyprland log (`hyprctl` instance dir → `hyprland.log`) and check your GPU's GBM support; running Hyprland as a real session (not nested) is the supported configuration. +## Troubleshooting: black client + "unavailable cursor mode 4" + +A black client, `pipeline build failed` in the host log, and **`unavailable cursor mode 4`** from +xdph are one failure, not three. + +`4` is the ScreenCast portal's *metadata* cursor mode, which the host prefers when the client draws +the pointer locally (desktop mouse mode). xdg-desktop-portal-hyprland **does not offer that mode** — +on a current stack (Hyprland 0.56.2, xdph 1.4.1) its `AvailableCursorModes` is `3`, meaning hidden +and embedded only. Asking for a mode the backend does not advertise is not a soft failure: +`xdg-desktop-portal` rejects the call outright, so the cast died during setup and the client had +nothing to show. + +Updating xdph does **not** fix this — the mode is absent on current versions, not just old ones. +Hosts from this release check what your portal advertises and use an embedded cursor instead, so the +session streams. If you are on an older host, switch the client to **game mouse mode**: that stops +it asking for the metadata cursor at all. + +If the pointer misbehaves on an xdph that *does* advertise metadata support, pin the mode: + +```sh +PUNKTFUNK_PORTAL_CURSOR_MODE=embedded +``` + +See [Configuration](/docs/configuration#compositor-specific-linux). + ## Permission system Hyprland's permission system (`ecosystem.enforce_permissions`, 0.49+, **off by default**) can deny diff --git a/docs-site/content/docs/sway.md b/docs-site/content/docs/sway.md index 361c2cf2..55992b63 100644 --- a/docs-site/content/docs/sway.md +++ b/docs-site/content/docs/sway.md @@ -82,6 +82,20 @@ For how long the virtual output lives, and extend-vs-exclusive topology, see Then `systemctl --user restart xdg-desktop-portal`. On a box with only xdpw installed there is nothing to choose between, so you can skip this. +## Troubleshooting: black client + "unsupported cursor mode requested" + +A black client with `pipeline build failed` in the host log and **`dbus: unsupported cursor mode +requested, cancelling`** from xdpw is one failure, not two. + +xdpw refuses the ScreenCast *metadata* cursor mode and cancels the cast, and the portal spec makes +that fatal rather than a fallback. Hosts before this release asked for it whenever the client drew +the pointer itself (desktop mouse mode), so those sessions never produced a frame. Hosts from this +release check what xdpw advertises first and use an embedded cursor instead, so the session streams. + +On an older host, switch the client to **game mouse mode** — it stops asking for the metadata cursor +and the stream comes up. The same failure on Hyprland reads `unavailable cursor mode 4`; see +[Hyprland](/docs/hyprland). + ## Start the host With the backend selected, start the host from **inside your Sway session**: