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