Hyprland/Sway black client — the wlr-family backends asserted a cursor mode instead of negotiating it #216
@@ -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<String>,
|
||||
/// `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<String>,
|
||||
/// `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<String>,
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -115,12 +115,21 @@ fn output_owner_pid(name: &str) -> Option<u32> {
|
||||
/// 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<AtomicBool>,
|
||||
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
|
||||
|
||||
@@ -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<Mode>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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<AtomicBool>,
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**:
|
||||
|
||||
Reference in New Issue
Block a user