From 349d16382eaaef11754d98b2ce8f9943df2bd0e8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 7 Jul 2026 22:11:23 +0200 Subject: [PATCH] fix(stats): decode stage measures GPU completion; Alt+Enter fullscreen alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vulkan path's receive_frame returns at SUBMISSION (~0.1 ms) — the hardware decodes asynchronously, so the decode stat was truthful but measured the wrong boundary. The pump now ships the frame to the presenter FIRST, then waits the frame's timeline fence (vkWaitSemaphores resolved through the shared device's proc chain) and stamps received→decode-COMPLETE — true NVDEC time at zero pipeline cost, since the presenter's own GPU wait is what actually gates sampling. Software/ VAAPI keep their synchronous stamps. Also: Alt+Enter joins F11 as the fullscreen toggle (some keyboards' Fn layer sends a media key for plain F11 — observed on glass as 'F11 only works with shift'); the shell's shortcuts panel lists both. Co-Authored-By: Claude Fable 5 --- clients/linux/src/app.rs | 2 +- crates/pf-client-core/src/session.rs | 29 +++++- crates/pf-client-core/src/video.rs | 68 ++++++++++++++ crates/pf-ffvk/build.rs | 4 + crates/pf-presenter/src/run.rs | 7 +- crates/punktfunk-host/src/gamestream/apps.rs | 3 +- .../src/vdisplay/linux/hyprland.rs | 51 ++++++++++- docs-site/content/docs/configuration.md | 4 +- docs-site/content/docs/hyprland.md | 90 +++++++++++++++++++ docs-site/content/docs/meta.json | 1 + docs-site/content/docs/requirements.md | 6 +- docs-site/content/docs/sway.md | 23 ++--- docs-site/content/docs/virtual-displays.md | 2 +- scripts/headless/portals.conf | 12 ++- 14 files changed, 276 insertions(+), 26 deletions(-) create mode 100644 docs-site/content/docs/hyprland.md diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 65dec580..fe4315cd 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -587,7 +587,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow Toggle fullscreen - F11 + F11 <Alt>Return diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 5807ff69..663a6f9d 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -74,7 +74,10 @@ pub struct Stats { /// `host + network`. An old host never emits 0xCF, so this stays false and the /// combined stage renders unchanged. pub split: bool, - /// p50 `decode` stage: received → decoded, single-clock client-local (ms). + /// p50 `decode` stage: received → decode COMPLETE, single-clock client-local (ms). + /// Hardware paths measure GPU completion via the frame's timeline fence (an async + /// decoder's submission returning in ~0.1 ms is not "decoded"); software measures + /// the synchronous CPU decode. pub decode_ms: f32, /// Unrecoverable network frame drops this window, and their share of /// received+lost (%). The OSD renders the counter line only when nonzero. @@ -343,13 +346,33 @@ fn pump( } pending_split.push_back((frame.pts_ns, hn / 1000)); } - // `decode` stage: received→decoded, single clock, no skew. - decode_us.push(decoded_ns.saturating_sub(received_ns) / 1000); + // Ship the frame FIRST, then settle the decode stat: on the + // Vulkan path receive_frame returns at SUBMISSION (~0.1 ms) and + // the hardware decodes asynchronously — waiting the frame's + // timeline fence here (after the presenter already has the + // frame) measures true received→decode-complete at zero + // pipeline cost. Software/VAAPI keep the synchronous stamp. + let hw_fence = match &image { + DecodedImage::VkFrame(v) => { + Some((v.timeline_sem, v.decode_done_value)) + } + _ => None, + }; let _ = frame_tx.force_send(DecodedFrame { pts_ns: frame.pts_ns, decoded_ns, image, }); + // `decode` stage: received→decode COMPLETE, single clock. + let decode_done_ns = match hw_fence { + Some((sem, value)) + if decoder.wait_hw_decoded(sem, value, 50_000_000) => + { + now_ns() + } + _ => decoded_ns, + }; + decode_us.push(decode_done_ns.saturating_sub(received_ns) / 1000); } Ok(None) => no_output_streak += 1, // Survivable (loss until the next IDR/RFI recovery) — keep feeding. diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index 3fe3c38a..8d1b9739 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -68,6 +68,12 @@ pub struct VkVideoFrame { /// The frame pool's VkFormat (`AVVulkanFramesContext.format[0]`, raw i32) — the /// multiplanar format the presenter builds its per-plane views against. pub vk_format: i32, + /// The frame's timeline semaphore (raw VkSemaphore; creation-constant) and the + /// value FFmpeg's decode submission signals on completion — the pump waits this + /// pair AFTER shipping the frame to measure true GPU decode time (zero pipeline + /// cost: the presenter already waits the same pair on the GPU). + pub timeline_sem: u64, + pub decode_done_value: u64, pub width: u32, pub height: u32, pub color: ColorDesc, @@ -313,6 +319,15 @@ impl Decoder { done(Backend::Software(SoftwareDecoder::new(codec_id)?)) } + /// Wait for a Vulkan-Video frame's GPU decode to complete (timeline semaphore) — + /// the pump's decode-stat measurement. `false` = not the Vulkan backend, or timeout. + pub fn wait_hw_decoded(&self, timeline_sem: u64, value: u64, timeout_ns: u64) -> bool { + match &self.backend { + Backend::Vulkan(v) => v.wait_timeline(timeline_sem, value, timeout_ns), + _ => false, + } + } + /// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration /// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP. pub fn take_keyframe_request(&mut self) -> bool { @@ -807,6 +822,10 @@ struct VulkanDecoder { hw_device: *mut ffmpeg::ffi::AVBufferRef, packet: *mut ffmpeg::ffi::AVPacket, frame: *mut ffmpeg::ffi::AVFrame, + /// `vkWaitSemaphores` on the shared device — the decode-complete measurement + /// (resolved through the same get_proc_addr chain FFmpeg uses). + wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores, + vk_device: pf_ffvk::VkDevice, /// Storage `AVVulkanDeviceContext` points into (extension string arrays + the /// feature chain) — FFmpeg reads the extension lists past init (frames-context /// setup keys code paths off them), so this lives exactly as long as `hw_device`. @@ -926,6 +945,26 @@ impl VulkanDecoder { return Err(averr("av_hwdevice_ctx_init(VULKAN)", r)); } + // vkWaitSemaphores for the pump's decode-complete stat: loader → + // vkGetDeviceProcAddr → device fn (core 1.2, guaranteed by our gate). + let gipa = (*hwctx) + .get_proc_addr + .expect("get_proc_addr was just set above"); + let gdpa: pf_ffvk::PFN_vkGetDeviceProcAddr = std::mem::transmute(gipa( + (*hwctx).inst, + c"vkGetDeviceProcAddr".as_ptr(), + )); + let wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores = std::mem::transmute(gdpa + .expect("vkGetDeviceProcAddr resolvable")( + (*hwctx).act_dev, + c"vkWaitSemaphores".as_ptr(), + )); + if wait_semaphores.is_none() { + ffi::av_buffer_unref(&mut hw_device); + bail!("vkWaitSemaphores unresolvable on this device"); + } + let vk_device = (*hwctx).act_dev; + let codec = ffi::avcodec_find_decoder(codec_id.into()); if codec.is_null() { ffi::av_buffer_unref(&mut hw_device); @@ -951,6 +990,8 @@ impl VulkanDecoder { hw_device, packet: ffi::av_packet_alloc(), frame: ffi::av_frame_alloc(), + wait_semaphores, + vk_device, _ctx_storage: store, }) } @@ -985,6 +1026,27 @@ impl VulkanDecoder { } } + /// Block until the timeline semaphore reaches `value` (GPU decode complete) or the + /// timeout passes. Pure measurement — the presenter's own GPU wait is what gates + /// sampling, so a timeout here only degrades the stat, never the picture. + fn wait_timeline(&self, sem: u64, value: u64, timeout_ns: u64) -> bool { + let sems = [sem as pf_ffvk::VkSemaphore]; + let values = [value]; + let info = pf_ffvk::VkSemaphoreWaitInfo { + sType: pf_ffvk::VkStructureType_VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO, + pNext: std::ptr::null(), + flags: 0, + semaphoreCount: 1, + pSemaphores: sems.as_ptr(), + pValues: values.as_ptr(), + }; + // SAFETY: resolved from this device at init; handles outlive the decoder. + let r = unsafe { + self.wait_semaphores.expect("checked at init")(self.vk_device, &info, timeout_ns) + }; + r == 0 // VK_SUCCESS (VK_TIMEOUT = 2) + } + /// Lift the decoded `AVVkFrame` into a [`VkVideoFrame`]: clone the AVFrame (the /// guard — keeps the image + frames context alive through present) and ship the /// POINTERS; the presenter reads the live sync state under the frames-context lock @@ -1024,12 +1086,18 @@ impl VulkanDecoder { ffi::av_frame_free(&mut clone); bail!("multi-image Vulkan frames unsupported (disjoint pool)"); } + // Safe without the frames lock: the handle is creation-constant and + // sem_value was last written by the decode submission on THIS thread. + let timeline_sem = (*vkf).sem[0] as u64; + let decode_done_value = (*vkf).sem_value[0]; Ok(VkVideoFrame { vkframe: vkf as usize, frames_ctx: fc as usize, lock_frame, unlock_frame, vk_format, + timeline_sem, + decode_done_value, width: (*self.frame).width as u32, height: (*self.frame).height as u32, color: ColorDesc::from_raw(self.frame), diff --git a/crates/pf-ffvk/build.rs b/crates/pf-ffvk/build.rs index f5212fce..b14e9580 100644 --- a/crates/pf-ffvk/build.rs +++ b/crates/pf-ffvk/build.rs @@ -57,6 +57,10 @@ fn main() { .allowlist_type("VkPhysicalDeviceVulkan13Features") // AVVulkanFramesContext.img_flags values (plane views need MUTABLE_FORMAT). .allowlist_type("VkImageCreateFlagBits") + // Timeline-semaphore wait — the pump measures true GPU decode completion. + .allowlist_type("VkSemaphoreWaitInfo") + .allowlist_type("PFN_vkWaitSemaphores") + .allowlist_type("PFN_vkGetDeviceProcAddr") // …plus nothing else of FFmpeg: the core types these structs reference only // ever appear behind pointers here, so keep them opaque instead of duplicating // ffmpeg-sys-next's definitions (callers cast pointers between the crates). diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index e5db14a1..89986349 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -366,8 +366,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result print_stats = !print_stats; continue; } - if sc == Scancode::F11 { + // F11 or Alt+Enter (some keyboards' Fn layer sends a media key for + // plain F11 — the Moonlight-standard alias always exists). + let alt_enter = sc == Scancode::Return + && keymod.intersects(Mod::LALTMOD | Mod::RALTMOD); + if sc == Scancode::F11 || alt_enter { fullscreen = !fullscreen; + tracing::debug!(fullscreen, "fullscreen toggle"); if let Err(e) = window.set_fullscreen(fullscreen) { tracing::warn!(error = %e, "fullscreen toggle"); } diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index c36b74f6..74cfc86b 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -34,7 +34,8 @@ fn parse_compositor(s: &str) -> Option { "kwin" | "kde" => Some(Kwin), "mutter" | "gnome" => Some(Mutter), "gamescope" => Some(Gamescope), - "wlroots" | "sway" => Some(Wlroots), + "hyprland" => Some(Hyprland), + "wlroots" | "sway" | "river" => Some(Wlroots), _ => None, } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs index fe6afed9..93d84a45 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs @@ -317,10 +317,20 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> { }; let mut errs = Vec::new(); for a in attempts { - match hyprctl_dispatch(a) { - Ok(()) => return Ok(()), - Err(e) => errs.push(format!("{e:#}")), + if let Err(e) = hyprctl_dispatch(a) { + errs.push(format!("{a:?}: {e:#}")); + continue; } + // Confirm the monitor actually adopted the mode — some versions print `ok` for a command + // they silently ignored (e.g. a Lua form the era doesn't accept), which would otherwise + // leave the output at the default 1080p60. If it didn't take, fall through to the other era. + if wait_mode_applied(a, name, mode, Duration::from_millis(800)) { + return Ok(()); + } + errs.push(format!( + "{a:?}: dispatched but monitor never adopted {}x{}", + mode.width, mode.height + )); } bail!( "hyprctl monitor rule failed on both config eras: {}", @@ -328,6 +338,41 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> { ) } +/// Poll until monitor `name` reports the requested width×height (the rule applies asynchronously), +/// up to `timeout`. Logs which command form finally took. Returns `false` on timeout. +fn wait_mode_applied(attempt: &[&str], name: &str, mode: Mode, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + if monitor_has_mode(name, mode).unwrap_or(false) { + tracing::debug!(output = %name, cmd = ?attempt, "monitor adopted the requested mode"); + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(50)); + } +} + +/// Does monitor `name` currently report `mode`'s resolution in `hyprctl -j monitors`? Checks +/// width×height (the capture-critical dimension); the fractional `refreshRate` isn't compared. +fn monitor_has_mode(name: &str, mode: Mode) -> Result { + let out = hyprctl(&["-j", "monitors"])?; + let monitors: serde_json::Value = + serde_json::from_str(&out).context("parse hyprctl -j monitors")?; + let Some(arr) = monitors.as_array() else { + return Ok(false); + }; + for m in arr { + if m.get("name").and_then(|n| n.as_str()) == Some(name) { + let w = m.get("width").and_then(|v| v.as_u64()).unwrap_or(0); + let h = m.get("height").and_then(|v| v.as_u64()).unwrap_or(0); + return Ok(w == mode.width as u64 && h == mode.height as u64); + } + } + Ok(false) +} + /// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.0`), /// cached for the process. A compositor upgrade + restart mid-host-life would leave this stale, but /// [`set_monitor_rule`] tries both eras regardless, so the cache is an optimization, not correctness. diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 55db3d78..a36e9152 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -34,10 +34,10 @@ On Linux the host **rewrites `WAYLAND_DISPLAY` / `XDG_CURRENT_DESKTOP` / `XDG_RU | Setting | Values | Meaning | |---|---|---| -| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` (aliases: `kde`/`plasma`, `gnome`, `sway`/`hyprland`) | Which backend creates the virtual display. **Leave unset to auto-detect;** set only to force one. | +| `PUNKTFUNK_COMPOSITOR` | `kwin` · `mutter` · `gamescope` · `wlroots` · `hyprland` (aliases: `kde`/`plasma`, `gnome`, `sway`/`wlr`) | Which backend creates the virtual display. `wlroots` is sway/River; `hyprland` is its own backend. **Leave unset to auto-detect;** set only to force one. | | `PUNKTFUNK_VIDEO_SOURCE` | `virtual` · `portal` | `virtual` creates a per-client display at the client's exact mode (the normal choice). `portal` captures an existing monitor instead. | | `PUNKTFUNK_ZEROCOPY` | `1` · `0` *(default on)* | GPU zero-copy capture→encode (dmabuf → CUDA → NVENC, or D3D11 on Windows). **On by default** — no need to set it; it falls back to a CPU path automatically. Set `0` to force the CPU path. One exception: Windows **Intel/QSV** keeps the CPU path by default until zero-copy is validated on Intel hardware — set `1` to try it there. | -| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots. Auto-detected with the compositor. | +| `PUNKTFUNK_INPUT_BACKEND` | `libei` · `gamescope` · `wlr` · `uinput` | How input is injected. `libei` for GNOME/KDE, `gamescope` for Bazzite/gamescope, `wlr` for Sway/wlroots **and Hyprland**. Auto-detected with the compositor. | | `PUNKTFUNK_ENCODER` | `auto` · `nvenc` · `vaapi` (Linux) · `amf` · `qsv` (Windows) · `software` | Encoder backend. `auto` (default) detects the GPU vendor: NVIDIA→NVENC, AMD→VAAPI/AMF, Intel→VAAPI/QSV. `software` (aliases `sw`/`openh264`) is the GPU-less H.264 path on both platforms — on Windows `auto` falls back to it when no GPU is found; on Linux it is **explicit-only** (`auto` never picks it). | | `PUNKTFUNK_RENDER_NODE` | path | Linux DRM render node for zero-copy (default `/dev/dri/renderD128`). Set on multi-GPU boxes to pick the right GPU. | diff --git a/docs-site/content/docs/hyprland.md b/docs-site/content/docs/hyprland.md new file mode 100644 index 00000000..2752db7c --- /dev/null +++ b/docs-site/content/docs/hyprland.md @@ -0,0 +1,90 @@ +--- +title: Hyprland +description: Configure a punktfunk host on a Hyprland session — headless output via hyprctl, capture via xdg-desktop-portal-hyprland. +--- + +Hyprland is a **first-class backend.** The host adds a per-client headless output at the client's +exact mode with `hyprctl`, captures it through the **xdg-desktop-portal-hyprland (xdph)** ScreenCast +portal (zero-copy dmabuf), and injects input via the wlroots virtual pointer/keyboard protocols — +which Hyprland still implements even after dropping wlroots in v0.42. + +This is a distinct backend from [Sway / wlroots](/docs/sway): Hyprland has its own IPC (`hyprctl`) +and its own portal (xdph), so it is auto-detected and driven separately. + +This page assumes the package is already installed — see [Arch](/docs/arch), [Ubuntu](/docs/ubuntu), +or [Fedora](/docs/fedora). + +> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of +> the machine, so keep it on a trusted LAN or VPN and require pairing. + +## host.env + +The host auto-detects a Hyprland session, so you usually need nothing here. To force the backend, set +these in `~/.config/punktfunk/host.env`: + +```ini +PUNKTFUNK_COMPOSITOR=hyprland +PUNKTFUNK_INPUT_BACKEND=wlr +PUNKTFUNK_VIDEO_SOURCE=virtual +# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU. +``` + +See [Configuration](/docs/configuration) for the full reference. + +## How it works + +- **Video** — the host runs `hyprctl output create headless PF-1` and applies a monitor rule for the + client's exact mode. Outputs are **named**, so there's no before/after diffing. Both config eras + are supported: `hyprctl keyword monitor …` (≤ 0.54) and the Lua `hyprctl eval 'hl.monitor{…}'` + (≥ 0.55), selected from `hyprctl version`. +- **Capture** — it captures that output through the **xdg-desktop-portal-hyprland (xdph)** ScreenCast + portal. To pick the output without a GUI on a headless host, the host writes a managed + `~/.config/hypr/xdph.conf` pointing xdph's `custom_picker_binary` at a small shim that selects the + new output automatically — no interactive picker dialog to answer. +- **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual + keyboard** protocols (Hyprland kept them). Gamepads and audio are compositor-independent. + +For how long the virtual output lives, and extend-vs-exclusive topology, see +[Virtual displays](/docs/virtual-displays). + +## Requirements + +- A running Hyprland session (any recent release; validate on both a ≤ 0.54 and a ≥ 0.55 install). +- **xdg-desktop-portal-hyprland (xdph)** installed and running — the host captures through its + ScreenCast portal, and steers its custom picker. Without it there is no video. +- The ScreenCast interface routed to xdph — see `scripts/headless/portals.conf` (a `[Hyprland]` + section pins `org.freedesktop.impl.portal.ScreenCast=hyprland`). + +## Permission system + +Hyprland's permission system (`ecosystem.enforce_permissions`, 0.49+, **off by default**) can deny +direct screencopy and virtual-input clients — and denial is **silent**: capture goes to *black +frames* and input is *dropped*, with no error. If you've enabled it, grant the host explicitly in +your Hyprland config: + +```ini +ecosystem { + enforce_permissions = true +} + +permission = /usr/bin/punktfunk-host, screencopy, allow +permission = /usr/bin/punktfunk-host, virtual-pointer, allow +permission = /usr/bin/punktfunk-host, virtual-keyboard, allow +``` + +The host logs a warning at startup when it detects enforcement is on. (Adjust the binary path to +where your package installed `punktfunk-host`.) + +## Start the host + +With the backend selected, start the host from **inside your Hyprland session**: + +```sh +systemctl --user enable --now punktfunk-host +journalctl --user -u punktfunk-host -f +``` + +## Bring up the console and pair + +Enable the web console, read its login password, and arm PIN pairing — see +[The Web Console](/docs/web-console). Then [connect a client](/docs/clients). diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index f6fbe322..09d041cb 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -20,6 +20,7 @@ "kde", "gnome", "gamescope", + "hyprland", "sway", "running-as-a-service", "virtual-displays", diff --git a/docs-site/content/docs/requirements.md b/docs-site/content/docs/requirements.md index 98e1b276..7216677b 100644 --- a/docs-site/content/docs/requirements.md +++ b/docs-site/content/docs/requirements.md @@ -26,11 +26,11 @@ is also available. Setup splits along two axes: you **install** the package per - [KDE Plasma (KWin)](/docs/kde) - [GNOME (Mutter)](/docs/gnome) - [Steam / gamescope](/docs/gamescope) +- [Hyprland](/docs/hyprland) - [Sway / wlroots](/docs/sway) -Pick your distro to install, then your desktop to configure — the two are independent. Other -wlroots compositors (Hyprland) work but aren't a primary target; the host still needs one of these -compositor backends to create a virtual display. +Pick your distro to install, then your desktop to configure — the two are independent. The host +needs one of these compositor backends to create a virtual display. > **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a > signed installer that registers a service and bundles a virtual-display driver (whose driver- diff --git a/docs-site/content/docs/sway.md b/docs-site/content/docs/sway.md index e737d3fa..543adfa0 100644 --- a/docs-site/content/docs/sway.md +++ b/docs-site/content/docs/sway.md @@ -1,12 +1,15 @@ --- title: Sway / wlroots -description: Configure a punktfunk host on a wlroots compositor (Sway, Hyprland). +description: Configure a punktfunk host on a wlroots compositor (Sway, River). --- -The wlroots family can host — but **Sway is the only validated path.** The host adds a per-client -headless output at the client's exact mode and captures it through the xdg-desktop-portal-wlr (xdpw) -ScreenCast portal, injecting input via the wlroots virtual pointer/keyboard protocols. Hyprland and -other wlroots compositors are best-effort (see [How it works](#how-it-works) for the caveat). +Sway (and other wlroots-proper compositors like River) can host: the host adds a per-client headless +output at the client's exact mode with `swaymsg create_output` and captures it through the +xdg-desktop-portal-wlr (xdpw) ScreenCast portal, injecting input via the wlroots virtual +pointer/keyboard protocols. + +> On **Hyprland**? It's a separate first-class backend (its own `hyprctl` IPC and xdph portal) — +> see [Hyprland](/docs/hyprland). This page is for sway and other wlroots-proper compositors. This is **not a primary target.** It works and is validated live on **sway 1.11** (zero-copy), but it sees far less testing than the KDE and GNOME paths — expect rougher edges. If you have a choice, @@ -24,7 +27,7 @@ The host auto-detects a wlroots session, so you usually need nothing here. To fo these in `~/.config/punktfunk/host.env`: ```ini -PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, hyprland +PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr (Hyprland has its own: PUNKTFUNK_COMPOSITOR=hyprland) PUNKTFUNK_INPUT_BACKEND=wlr PUNKTFUNK_VIDEO_SOURCE=virtual # GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU. @@ -35,9 +38,8 @@ See [Configuration](/docs/configuration) for the full reference. ## How it works - **Video** — the host adds a headless output at the client's exact mode with `swaymsg create_output`. - This uses Sway's IPC specifically; other wlroots compositors (Hyprland, …) don't expose an - equivalent, so virtual-output creation isn't wired up for them yet — Sway is the supported wlroots - path today. + This uses Sway's IPC specifically; other wlroots-proper compositors (River, …) are best-effort on + this path. (Hyprland is driven by its own [backend](/docs/hyprland), not this one.) - **Capture** — it captures that output through the **xdg-desktop-portal-wlr (xdpw)** ScreenCast portal. The host writes a managed chooser config so the output pick is automatic — no interactive picker dialog to answer. @@ -49,7 +51,8 @@ For how long the virtual output lives, and extend-vs-exclusive topology, see ## Requirements -- A running wlroots session (Sway, Hyprland, …). +- A running wlroots-proper session (Sway, River, …). On Hyprland, use the + [Hyprland backend](/docs/hyprland) instead. - **xdg-desktop-portal-wlr (xdpw)** installed and running — the host captures through its ScreenCast portal. Without it there is no video. diff --git a/docs-site/content/docs/virtual-displays.md b/docs-site/content/docs/virtual-displays.md index b6fefc23..ee7a2a7b 100644 --- a/docs-site/content/docs/virtual-displays.md +++ b/docs-site/content/docs/virtual-displays.md @@ -101,7 +101,7 @@ What punktfunk does with your monitor layout while it streams. Per-backend support: -| | KWin | Mutter/GNOME | Sway/wlroots | Windows | +| | KWin | Mutter/GNOME | Sway/wlroots · Hyprland | Windows | |---|---|---|---|---| | Extend | ✅ | ✅ | ✅ | ✅ | | Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ | diff --git a/scripts/headless/portals.conf b/scripts/headless/portals.conf index 32192fa0..eb67376b 100644 --- a/scripts/headless/portals.conf +++ b/scripts/headless/portals.conf @@ -1,5 +1,15 @@ # ~/.config/xdg-desktop-portal/sway-portals.conf (xdg-desktop-portal 1.18+ format) -# Force the wlroots backend to service ScreenCast; gtk handles the rest. +# Route ScreenCast to the right wlr-family backend; gtk handles the rest. +# +# A desktop-specific section (matched against XDG_CURRENT_DESKTOP, which the host sets per session — +# `sway` for sway/river, `Hyprland` for Hyprland) wins over [preferred]. So sway hosts capture +# through xdg-desktop-portal-wlr (xdpw) and Hyprland hosts through xdg-desktop-portal-hyprland +# (xdph) — each the backend the host actually steers. [preferred] default=gtk org.freedesktop.impl.portal.ScreenCast=wlr + +# Hyprland: XDG_CURRENT_DESKTOP=Hyprland → xdph services ScreenCast (the host steers its custom +# picker via ~/.config/hypr/xdph.conf). See docs/hyprland. +[Hyprland] +org.freedesktop.impl.portal.ScreenCast=hyprland