From 925130d1f9152f483100f053ca874578c35e3901 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 22 Jul 2026 20:41:58 +0200 Subject: [PATCH] feat(vdisplay): compositor-embedded pointer for sessions without the cursor channel (Phase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the cursor-channel work, every Linux virtual output was created in metadata pointer mode — making ALL sessions depend on host-side cursor compositing, including the ones that can never use the channel (Moonlight/ GameStream, legacy clients, capture-mode starts). Those sessions paid the blend bring-up per session and, whenever a visible cursor was composited, the loss of NVENC's stream-ordered submit — for a strictly worse cursor than the compositor's own. Mirror the Windows no-regression gate: the already-wired per-session set_hw_cursor(cursor_forward) now drives the Linux backends too. A cursor-channel session gets metadata (shapes forwarded, composite flip blends host-side — today's validated path, unchanged); every other session gets the pointer compositor-EMBEDDED at creation (KWin zkde pointer=2, Mutter cursor-mode=1, wlroots/hyprland portal CursorMode::Embedded — their pre-channel default; the portal pair also gains the Metadata arm for channel sessions, wired but untested on-glass). SessionPlan.cursor_blend narrows to cursor-forward sessions and rides into the direct-SDK NVENC open, which skips the Vulkan slot-blend bring-up entirely when off — embedded sessions ring on plain CUDA surfaces and pay zero cursor cost, per-session or per-frame. The keep-alive registry's reuse key grows the created pointer mode (new VirtualDisplay::hw_cursor getter): a kept embedded display has no cursor metadata for a channel session to forward, and a kept metadata display would leave a channel-less session with no pointer in its frames. Co-Authored-By: Claude Fable 5 --- crates/pf-encode/src/enc/linux/nvenc_cuda.rs | 15 ++++-- crates/pf-encode/src/lib.rs | 17 ++++--- crates/pf-vdisplay/src/vdisplay/backend.rs | 24 ++++++--- .../src/vdisplay/linux/hyprland.rs | 37 ++++++++++++-- crates/pf-vdisplay/src/vdisplay/linux/kwin.rs | 50 +++++++++++++++---- .../pf-vdisplay/src/vdisplay/linux/mutter.rs | 37 ++++++++++++-- .../pf-vdisplay/src/vdisplay/linux/wlroots.rs | 37 ++++++++++++-- crates/pf-vdisplay/src/vdisplay/registry.rs | 8 +++ .../src/vdisplay/windows/pf_vdisplay.rs | 4 ++ crates/punktfunk-host/src/native/stream.rs | 22 +++++--- 10 files changed, 205 insertions(+), 46 deletions(-) diff --git a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs index 119a0ee2..25ee82f8 100644 --- a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -573,6 +573,11 @@ pub struct NvencCudaEncoder { /// allocations and composite mode degrades to no cursor. `cursor_serial` tracks the /// uploaded bitmap. vk_blend: Option, + /// The session may hand this encoder cursor overlays (`SessionPlan.cursor_blend` — only + /// cursor-channel sessions since Phase B). Off = skip the Vulkan bring-up entirely and ring + /// on plain CUDA surfaces: embedded-pointer sessions never carry an overlay, so they pay + /// zero blend cost, per-session or per-frame. + blend_wanted: bool, cursor_tried: bool, cursor_serial: u64, /// Suppress-until-success latch for the per-frame blend warn: a persistent failure sits in @@ -645,6 +650,7 @@ impl NvencCudaEncoder { _cuda: bool, bit_depth: u8, chroma: ChromaFormat, + cursor_blend: bool, ) -> Result { // The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a // clear reason instead of an opaque session error on the first frame. @@ -684,6 +690,7 @@ impl NvencCudaEncoder { force_kf: false, pending_anchor: false, vk_blend: None, + blend_wanted: cursor_blend, cursor_tried: false, cursor_serial: u64::MAX, cursor_blend_warned: false, @@ -1174,7 +1181,7 @@ impl NvencCudaEncoder { // SPIR-V cursor blend can composite into the very bytes NVENC encodes; any bring-up // or per-slot failure falls back to plain pitched CUDA allocations (sessions always // encode — composite mode just loses the cursor, warned below). - if !self.cursor_tried { + if !self.cursor_tried && self.blend_wanted { self.cursor_tried = true; match VkSlotBlend::new() { Ok(v) => self.vk_blend = Some(v), @@ -1576,8 +1583,10 @@ impl Encoder for NvencCudaEncoder { } else if !self.cursor_blend_warned { self.cursor_blend_warned = true; tracing::warn!( - "NVENC (Linux): cursor overlay present but no Vulkan blend (bring-up failed \ - earlier) — cursor not composited" + blend_wanted = self.blend_wanted, + "NVENC (Linux): cursor overlay present but no Vulkan blend (bring-up failed, \ + or a non-blend session unexpectedly carried an overlay) — cursor not \ + composited" ); } } diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index 4fbc6d8d..fb4edefc 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -270,7 +270,7 @@ fn open_video_backend( chroma: ChromaFormat, cursor_blend: bool, ) -> Result<(Box, &'static str)> { - let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below + let _ = cursor_blend; // consumed only by the Linux vulkan-encode + direct-NVENC arms below validate_dimensions(codec, width, height)?; // Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`) // and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero. @@ -394,6 +394,7 @@ fn open_video_backend( cuda, bit_depth, chroma, + cursor_blend, ) .map(|e| (e, "nvenc")) }; @@ -729,12 +730,15 @@ fn open_nvenc_probed( cuda: bool, bit_depth: u8, chroma: ChromaFormat, + cursor_blend: bool, ) -> Result> { - // Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA - // capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those - // keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set - // PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own - // level-ceiling binary search at session open), so it skips the probe-loop stepping below. + #[cfg(not(feature = "nvenc"))] + let _ = cursor_blend; // consumed by the direct-SDK arm below + // Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA + // capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those + // keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set + // PUNKTFUNK_NVENC_DIRECT=0 to fall back to libav. It self-clamps the bitrate internally (its own + // level-ceiling binary search at session open), so it skips the probe-loop stepping below. #[cfg(feature = "nvenc")] if cuda && nvenc_direct_enabled() { tracing::info!( @@ -751,6 +755,7 @@ fn open_nvenc_probed( cuda, bit_depth, chroma, + cursor_blend, )?) as Box); } // The silent-degrade trap: a build without `--features nvenc` compiles the direct-SDK diff --git a/crates/pf-vdisplay/src/vdisplay/backend.rs b/crates/pf-vdisplay/src/vdisplay/backend.rs index d24bd58e..30abd077 100644 --- a/crates/pf-vdisplay/src/vdisplay/backend.rs +++ b/crates/pf-vdisplay/src/vdisplay/backend.rs @@ -142,13 +142,25 @@ pub trait VirtualDisplay: Send { /// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint /// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us). fn set_client_hdr(&mut self, _hdr: Option) {} - /// Ask the backend for an out-of-band HARDWARE CURSOR on the created output (the M2c cursor - /// channel): the compositor/OS stops compositing the pointer into captured frames and the - /// capture layer surfaces shape/position separately. Carried on the backend instance; set - /// once before [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay - /// backend (IddCx hardware cursor, driver proto v5) implements it; the Linux portal path - /// gets the same split from `SPA_META_Cursor` without asking. + /// Ask the backend for an OUT-OF-BAND cursor on the created output (the cursor channel): + /// the compositor/OS stops compositing the pointer into captured frames and the capture + /// layer surfaces shape/position separately. Carried on the backend instance; set once + /// before [`create`](Self::create) (both session paths pass `cursor_forward`). Off = the + /// compositor EMBEDS the pointer into frames — zero host-side cursor work, the pre-channel + /// path — which is what every session without the negotiated cursor cap gets (Moonlight / + /// GameStream / legacy clients / capture-mode starts), mirroring the Windows no-regression + /// gate. Implementations: Windows pf-vdisplay (IddCx hardware cursor, driver proto v5); + /// KWin (zkde `pointer` metadata vs embedded); Mutter (`cursor-mode` metadata vs embedded); + /// wlroots/hyprland (portal `CursorMode`). Default: no-op (gamescope has no cursor either + /// way — see the Phase C source). fn set_hw_cursor(&mut self, _on: bool) {} + /// The out-of-band-cursor request currently set (see [`set_hw_cursor`](Self::set_hw_cursor)). + /// The registry includes it in the keep-alive REUSE key: a kept embedded-pointer display can + /// never serve a cursor-channel session (its stream has no cursor metadata to forward) nor + /// vice versa (the pointer would be missing from frames). + fn hw_cursor(&self) -> bool { + false + } /// The stable identity slot the backend resolved for the most recent [`create`](Self::create) — /// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The /// registry reads it right after `create` to key the display's group **arrangement** (manual diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index 4ade5a37..afcdbb65 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -92,11 +92,19 @@ fn next_output_name() -> String { /// 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; +pub struct HyprlandDisplay { + /// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): 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). + hw_cursor: bool, +} impl HyprlandDisplay { pub fn new() -> Result { - Ok(HyprlandDisplay) + Ok(HyprlandDisplay { hw_cursor: false }) } } @@ -141,6 +149,14 @@ impl VirtualDisplay for HyprlandDisplay { "hyprland" } + fn set_hw_cursor(&mut self, on: bool) { + self.hw_cursor = on; + } + + fn hw_cursor(&self) -> bool { + self.hw_cursor + } + fn create(&mut self, mode: Mode) -> Result { // Log the permission-system caveat once per process (silent black frames otherwise). preflight_once(); @@ -167,9 +183,10 @@ impl VirtualDisplay for HyprlandDisplay { let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); + let hw_cursor = self.hw_cursor; thread::Builder::new() .name("punktfunk-hypr-vout".into()) - .spawn(move || portal_thread(setup_tx, stop_thread)) + .spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor)) .context("spawn hyprland portal thread")?; let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { @@ -491,7 +508,17 @@ fn ensure_xdph_config() -> Result<()> { /// stopped (the zbus connection is the cast's lifetime). xdph answers source selection via our /// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays /// self-owned per D1; unify if they ever diverge no further.) -fn portal_thread(setup_tx: Sender>, stop: Arc) { +fn portal_thread( + setup_tx: Sender>, + 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::PersistMode; use ashpd::enumflags2::BitFlags; @@ -524,7 +551,7 @@ fn portal_thread(setup_tx: Sender>, stop: Arc`. const VOUT_NAME: &str = "punktfunk"; @@ -92,6 +94,9 @@ pub struct KwinDisplay { /// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's. /// A backstop [`Drop`] runs it if the registry never took it (so a physical is never left dark). pending_restore: Option>, + /// Out-of-band cursor request (`set_hw_cursor`, i.e. the session negotiated the cursor + /// channel): METADATA pointer mode at creation; off = EMBEDDED (see the consts above). + hw_cursor: bool, } impl Drop for KwinDisplay { @@ -128,6 +133,14 @@ impl VirtualDisplay for KwinDisplay { self.pending_restore.take() } + fn set_hw_cursor(&mut self, on: bool) { + self.hw_cursor = on; + } + + fn hw_cursor(&self) -> bool { + self.hw_cursor + } + fn apply_position(&mut self, x: i32, y: i32) { // `last_name` holds the RESOLVED kscreen address (numeric output id, or the // `Virtual-` fallback) — never re-derive from the name: during a supersede two @@ -166,6 +179,11 @@ impl VirtualDisplay for KwinDisplay { }; self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout) let (width, height) = (mode.width, mode.height); + let pointer_mode = if self.hw_cursor { + POINTER_METADATA + } else { + POINTER_EMBEDDED + }; let spawn_vout = |w: u32, h: u32| -> Result<(u32, Arc)> { let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); @@ -173,7 +191,9 @@ impl VirtualDisplay for KwinDisplay { let name_thread = name.clone(); thread::Builder::new() .name("punktfunk-kwin-vout".into()) - .spawn(move || virtual_output_thread(w, h, name_thread, setup_tx, stop_thread)) + .spawn(move || { + virtual_output_thread(w, h, name_thread, pointer_mode, setup_tx, stop_thread) + }) .context("spawn KWin virtual-output thread")?; match setup_rx.recv_timeout(Duration::from_secs(20)) { Ok(Ok(v)) => Ok((v, stop)), @@ -201,7 +221,14 @@ impl VirtualDisplay for KwinDisplay { let want_high = mode.refresh_hz > 60; let birth_h = if want_high { height + 16 } else { height }; let (mut node_id, mut stop) = spawn_vout(width, birth_h)?; - tracing::info!(node_id, width, height, birth_h, "KWin virtual output ready"); + tracing::info!( + node_id, + width, + height, + birth_h, + embedded_pointer = !self.hw_cursor, + "KWin virtual output ready" + ); // ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the // replacement output — SAME per-slot name, deliberately, for KWin's per-name config // persistence — while the superseded sibling is still alive (create-before-drop). Every @@ -716,10 +743,11 @@ fn virtual_output_thread( width: u32, height: u32, name: String, + pointer_mode: u32, setup_tx: Sender>, stop: Arc, ) { - if let Err(e) = run(width, height, &name, &setup_tx, &stop) { + if let Err(e) = run(width, height, &name, pointer_mode, &setup_tx, &stop) { // If we never delivered a node id, report the failure to the waiting opener. let _ = setup_tx.send(Err(format!("{e:#}"))); } @@ -760,6 +788,7 @@ fn run( width: u32, height: u32, name: &str, + pointer_mode: u32, setup_tx: &Sender>, stop: &AtomicBool, ) -> Result<()> { @@ -780,13 +809,14 @@ fn run( ) })?; - // Create the virtual output sized to the client, cursor riding as stream metadata. + // Create the virtual output sized to the client; the pointer rides as stream metadata + // (cursor-channel session) or KWin embeds it into frames (everyone else — see the consts). let stream = screencast.stream_virtual_output( name.to_string(), width as i32, height as i32, 1.0, // scale (logical == physical) - POINTER_METADATA, + pointer_mode, &qh, (), ); diff --git a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs index 55da2043..6af22925 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs @@ -53,6 +53,11 @@ const APPLY_TEMPORARY: u32 = 1; /// Embedded mode would leave BOTH paths blind: no metadata means nothing to forward AND nothing /// to blend, and Mutter's own embedded painting is what the pre-channel path relied on. const CURSOR_METADATA: u32 = 2; +/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself — +/// zero host-side cursor work, the pre-channel path. Chosen for every session WITHOUT the +/// negotiated cursor channel (`set_hw_cursor` off — Phase B, the Windows no-regression gate +/// mirrored); metadata stays the cursor-channel sessions' mode (shapes forwarded / host blend). +const CURSOR_EMBEDDED: u32 = 1; /// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies /// a monitor configuration. Each of these makes Mutter rebuild its monitor topology, and @@ -74,6 +79,9 @@ pub struct MutterDisplay { /// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone /// session establishes topology as before). first_in_group: bool, + /// Out-of-band cursor request (`set_hw_cursor`): metadata cursor-mode at creation; off = + /// embedded (see [`CURSOR_EMBEDDED`]). + hw_cursor: bool, /// The connecting client's cert fingerprint (set before [`create`](VirtualDisplay::create)) — /// keys the per-client persisted **scale** (GNOME can't persist it itself: Mutter mints a fresh /// EDID serial per `RecordVirtual` monitor, so `monitors.xml` never rematches; see @@ -89,6 +97,7 @@ impl MutterDisplay { pub fn new() -> Result { Ok(MutterDisplay { first_in_group: true, + hw_cursor: false, client_fp: None, last_slot: None, }) @@ -117,6 +126,14 @@ impl VirtualDisplay for MutterDisplay { self.client_fp = fingerprint; } + fn set_hw_cursor(&mut self, on: bool) { + self.hw_cursor = on; + } + + fn hw_cursor(&self) -> bool { + self.hw_cursor + } + fn last_identity_slot(&self) -> Option { self.last_slot } @@ -146,6 +163,7 @@ impl VirtualDisplay for MutterDisplay { let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); let first_in_group = self.first_in_group; + let hw_cursor = self.hw_cursor; thread::Builder::new() .name("punktfunk-mutter-vout".into()) .spawn(move || { @@ -154,6 +172,7 @@ impl VirtualDisplay for MutterDisplay { stop_thread, mode, first_in_group, + hw_cursor, scale_key, remembered_scale, ) @@ -208,6 +227,7 @@ fn session_thread( stop: Arc, mode: Mode, first_in_group: bool, + hw_cursor: bool, scale_key: String, remembered_scale: Option, ) { @@ -267,7 +287,7 @@ fn session_thread( } }; - let session = match connect(mode, remembered_scale).await { + let session = match connect(mode, hw_cursor, remembered_scale).await { Ok(s) => s, Err(e) => { let _ = setup_tx.send(Err(format!("{e:#}"))); @@ -368,7 +388,11 @@ struct MutterSession { /// desktop scale, passed as the virtual mode's `preferred-scale` so Mutter creates the monitor /// already scaled (Mutter ≥ 48; older Mutter ignores unknown mode keys) — this covers the /// `extend` topology, where we never issue our own ApplyMonitorsConfig. -async fn connect(mode: Mode, preferred_scale: Option) -> Result { +async fn connect( + mode: Mode, + hw_cursor: bool, + preferred_scale: Option, +) -> Result { let conn = zbus::Connection::session() .await .context("connect session D-Bus")?; @@ -429,7 +453,14 @@ async fn connect(mode: Mode, preferred_scale: Option) -> Result = HashMap::new(); - rec.insert("cursor-mode", Value::from(CURSOR_METADATA)); + rec.insert( + "cursor-mode", + Value::from(if hw_cursor { + CURSOR_METADATA + } else { + CURSOR_EMBEDDED + }), + ); if mode.refresh_hz > 60 || preferred_scale.is_some() { let mut vmode: HashMap<&str, Value> = HashMap::new(); vmode.insert("size", Value::from((mode.width, mode.height))); diff --git a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs index d732c2dd..032fb597 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs @@ -54,11 +54,19 @@ chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n", /// 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; +pub struct WlrootsDisplay { + /// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): 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). + hw_cursor: bool, +} impl WlrootsDisplay { pub fn new() -> Result { - Ok(WlrootsDisplay) + Ok(WlrootsDisplay { hw_cursor: false }) } } @@ -73,6 +81,14 @@ impl VirtualDisplay for WlrootsDisplay { "wlroots" } + fn set_hw_cursor(&mut self, on: bool) { + self.hw_cursor = on; + } + + fn hw_cursor(&self) -> bool { + self.hw_cursor + } + fn create(&mut self, mode: Mode) -> Result { let before = output_names() .context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?; @@ -104,9 +120,10 @@ impl VirtualDisplay for WlrootsDisplay { let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); + let hw_cursor = self.hw_cursor; thread::Builder::new() .name("punktfunk-wlr-vout".into()) - .spawn(move || portal_thread(setup_tx, stop_thread)) + .spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor)) .context("spawn wlroots portal thread")?; let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { @@ -255,7 +272,17 @@ fn ensure_xdpw_config() -> Result<()> { /// The ScreenCast portal handshake (same shape as the capture module's portal thread, but it /// reports the fd + node id and parks until stopped — the zbus connection is the cast's /// lifetime). xdpw answers the source selection via the chooser, no dialog. -fn portal_thread(setup_tx: Sender>, stop: Arc) { +fn portal_thread( + setup_tx: Sender>, + 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::PersistMode; use ashpd::enumflags2::BitFlags; @@ -288,7 +315,7 @@ fn portal_thread(setup_tx: Sender>, stop: Arc bool { + self.hw_cursor + } + fn set_quit_flag(&mut self, quit: std::sync::Arc) { self.quit = Some(quit); } diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index a7c059c9..a6ef8c18 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1000,12 +1000,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option