diff --git a/crates/pf-presenter/src/input.rs b/crates/pf-presenter/src/input.rs index 6fce0fe5..d7420b67 100644 --- a/crates/pf-presenter/src/input.rs +++ b/crates/pf-presenter/src/input.rs @@ -1,16 +1,19 @@ -//! Input capture — the `ui_stream` state machine on SDL events. +//! Input capture — the `ui_stream` state machine on SDL events, upgraded to real +//! pointer lock (the "stage-2 presenter's job" the GTK client deferred). //! //! Capture is a deliberate, reversible STATE (Moonlight-style): engaged when the stream //! starts and when the user clicks into the video (that click is suppressed toward the //! host); released by Ctrl+Alt+Shift+Q (toggles) or focus loss — held keys/buttons are -//! flushed host-side on release so nothing sticks down. While captured the local cursor -//! is hidden (the host renders its own); while released nothing is forwarded. +//! flushed host-side on release so nothing sticks down. While captured the pointer is +//! LOCKED (SDL relative mouse mode: hidden, confined, raw deltas) and motion goes on +//! the wire as RELATIVE `MouseMove` — the local cursor can't outrun or escape the +//! stream, so the only cursor you see is the host's. An auto-release from focus loss +//! re-engages on focus gain; an explicit user release (the chord) stays released until +//! the user opts back in. //! -//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Mouse is absolute -//! (`MouseMoveAbs` scaled into the negotiated mode through the letterbox transform, -//! window size packed in `flags` — the GTK client's exact contract); motion is coalesced -//! to one datagram per loop iteration. Pointer-lock relative capture is the follow-up -//! (§5.3 of the plan) — absolute first for GTK parity. +//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are +//! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would +//! otherwise send a datagram per event). use crate::keymap_sdl; use punktfunk_core::client::NativeClient; @@ -21,13 +24,13 @@ use std::sync::Arc; pub struct Capture { connector: Arc, captured: bool, + /// The user released deliberately (the chord) — focus-gain must NOT re-engage. + user_released: bool, /// VKs / GameStream button ids currently held — flushed up on release. held_keys: HashSet, held_buttons: HashSet, - /// Newest absolute pointer position (window coordinates) not yet on the wire — - /// motion events only store here; `flush_motion` sends at most one `MouseMoveAbs` - /// per loop iteration (a 1000 Hz mouse would otherwise send a datagram per event). - pending_abs: Option<(f32, f32)>, + /// Relative motion not yet on the wire, summed per loop iteration. + pending_rel: (i32, i32), /// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space — /// precision surfaces deliver sub-unit deltas; truncating each event drops the tail. scroll_acc: (f64, f64), @@ -49,9 +52,10 @@ impl Capture { Capture { connector, captured: false, + user_released: false, held_keys: HashSet::new(), held_buttons: HashSet::new(), - pending_abs: None, + pending_rel: (0, 0), scroll_acc: (0.0, 0.0), } } @@ -60,18 +64,29 @@ impl Capture { self.captured } - /// Engage capture. The caller hides the cursor (SDL state lives with the loop). + /// Whether a regained focus should re-engage: yes unless the user released + /// deliberately (the chord keeps its meaning across an Alt-Tab). + pub fn should_reengage(&self) -> bool { + !self.captured && !self.user_released + } + + /// Engage capture. The caller flips SDL relative mouse mode on (pointer lock). pub fn engage(&mut self) -> bool { + self.user_released = false; !std::mem::replace(&mut self.captured, true) } /// Release capture, flushing everything held so nothing sticks down on the host. - /// The caller restores the cursor. Returns false if it wasn't engaged. - pub fn release(&mut self) -> bool { + /// `by_user` = the chord (stays released); focus loss re-engages on focus gain. + /// The caller flips SDL relative mouse mode off. Returns false if not engaged. + pub fn release(&mut self, by_user: bool) -> bool { + if by_user { + self.user_released = true; + } if !std::mem::replace(&mut self.captured, false) { return false; } - self.pending_abs = None; // never flush motion gathered while captured + self.pending_rel = (0, 0); // never flush motion gathered while captured for vk in self.held_keys.drain() { send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); } @@ -81,44 +96,30 @@ impl Capture { true } - /// Forward the coalesced pointer position, if any — one datagram per loop iteration. - /// `win` is the window's logical size (SDL mouse coordinates' space). - pub fn flush_motion(&mut self, win: (u32, u32)) { - if let Some((x, y)) = self.pending_abs.take() { - self.send_abs(x, y, win); + /// Forward the coalesced motion delta, if any — one datagram per loop iteration. + pub fn flush_motion(&mut self) { + let (dx, dy) = std::mem::take(&mut self.pending_rel); + if dx != 0 || dy != 0 { + send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0); } } - /// Absolute position: window coordinates → video pixels through the Contain-fit - /// letterbox (the blit uses the same math in pixel space — the ratios are identical). - /// `flags` packs the coordinate-space size (`(w << 16) | h`) — the host normalizes - /// against it before mapping into the EIS region; without it the event is dropped. - fn send_abs(&self, x: f32, y: f32, win: (u32, u32)) { - let mode = self.connector.mode(); - let (ww, wh) = (win.0.max(1) as f64, win.1.max(1) as f64); - let (vw, vh) = (mode.width.max(1) as f64, mode.height.max(1) as f64); - let scale = (ww / vw).min(wh / vh); - let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0); - let px = ((f64::from(x) - ox) / scale).round().clamp(0.0, vw - 1.0) as i32; - let py = ((f64::from(y) - oy) / scale).round().clamp(0.0, vh - 1.0) as i32; - let flags = (mode.width << 16) | (mode.height & 0xffff); - send(&self.connector, InputKind::MouseMoveAbs, 0, px, py, flags); - } - - pub fn on_motion(&mut self, x: f32, y: f32) { + /// Relative motion (SDL relative mouse mode delivers raw deltas while locked). + pub fn on_motion(&mut self, xrel: f32, yrel: f32) { if self.captured { - self.pending_abs = Some((x, y)); + self.pending_rel.0 += xrel as i32; + self.pending_rel.1 += yrel as i32; } } - pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode, win: (u32, u32)) { + pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) { if !self.captured { return; } if let Some(vk) = keymap_sdl::scancode_to_vk(sc) { // Keep the wire ordered: the host must see the cursor where the user does // when the key lands (e.g. "press E at the crosshair"). - self.flush_motion(win); + self.flush_motion(); self.held_keys.insert(vk); send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0); } @@ -134,22 +135,21 @@ impl Capture { } /// A button press while captured. The engaging click is the caller's business (it - /// never reaches here). The click's own coordinates supersede pending motion so the - /// button-down lands exactly where the cursor last was. - pub fn on_button_down(&mut self, b: sdl3::mouse::MouseButton, x: f32, y: f32, win: (u32, u32)) { + /// never reaches here). Pending motion flushes first so the button-down lands where + /// the host cursor actually is. + pub fn on_button_down(&mut self, b: sdl3::mouse::MouseButton) { if !self.captured { return; } - self.pending_abs = Some((x, y)); - self.flush_motion(win); + self.flush_motion(); if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) { self.held_buttons.insert(gs); send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0); } } - pub fn on_button_up(&mut self, b: sdl3::mouse::MouseButton, win: (u32, u32)) { - self.flush_motion(win); // the release must not beat the motion before it + pub fn on_button_up(&mut self, b: sdl3::mouse::MouseButton) { + self.flush_motion(); // the release must not beat the motion before it if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) { if self.held_buttons.remove(&gs) { send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0); @@ -158,13 +158,13 @@ impl Capture { } /// Wheel — the wire carries WHEEL_DELTA(120) units, positive = up / right. SDL3's y - /// is positive = up and x positive = right already (no negation — GTK's dy was the - /// inverted one). Fractional remainders accumulate per axis. - pub fn on_wheel(&mut self, dx: f32, dy: f32, win: (u32, u32)) { + /// is positive = up and x positive = right already. Fractional remainders + /// accumulate per axis. + pub fn on_wheel(&mut self, dx: f32, dy: f32) { if !self.captured { return; } - self.flush_motion(win); // scroll happens at the latest cursor position + self.flush_motion(); // scroll happens at the latest cursor position let (mut ax, mut ay) = self.scroll_acc; ay += f64::from(dy) * 120.0; ax += f64::from(dx) * 120.0; diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 26a35d25..e5db14a1 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -175,7 +175,7 @@ impl StreamState { /// The pump then emits `Ended(None)` — the loop's normal end path picks it up. fn request_quit(&mut self) { if let Some(cap) = &mut self.capture { - cap.release(); + cap.release(true); } if let Some(c) = &self.connector { c.disconnect_quit(); @@ -299,12 +299,25 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result Event::Window { win_event, .. } => match win_event { WindowEvent::FocusLost => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - if cap.release() { + if cap.release(false) { + mouse.set_relative_mouse_mode(&window, false); mouse.show_cursor(true); tracing::info!("focus lost — input released"); } } } + WindowEvent::FocusGained => { + // An auto-release (Alt-Tab) undoes itself; a chord release + // stays released until the user opts back in. + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { + if cap.should_reengage() { + cap.engage(); + mouse.set_relative_mouse_mode(&window, true); + mouse.show_cursor(false); + tracing::info!("focus gained — input recaptured"); + } + } + } WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => { presenter.recreate_swapchain(&window)?; presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?; @@ -327,10 +340,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if chord && sc == Scancode::Q { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.captured() { - cap.release(); + cap.release(true); + mouse.set_relative_mouse_mode(&window, false); mouse.show_cursor(true); } else { cap.engage(); + mouse.set_relative_mouse_mode(&window, true); mouse.show_cursor(false); } tracing::info!(captured = cap.captured(), "chord: release/engage"); @@ -341,6 +356,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = &mut stream { tracing::info!("chord: disconnect"); st.request_quit(); + mouse.set_relative_mouse_mode(&window, false); mouse.show_cursor(true); // The pump emits Ended(None); the end path routes per mode. } @@ -358,7 +374,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result continue; } if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - cap.on_key_down(sc, window.size()); + cap.on_key_down(sc); } } Event::KeyUp { @@ -368,32 +384,31 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result cap.on_key_up(sc); } } - Event::MouseMotion { x, y, .. } => { + Event::MouseMotion { xrel, yrel, .. } => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - cap.on_motion(x, y); + cap.on_motion(xrel, yrel); } } - Event::MouseButtonDown { - mouse_btn, x, y, .. - } => { + Event::MouseButtonDown { mouse_btn, .. } => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if !cap.captured() { // The engaging click is suppressed toward the host. cap.engage(); + mouse.set_relative_mouse_mode(&window, true); mouse.show_cursor(false); } else { - cap.on_button_down(mouse_btn, x, y, window.size()); + cap.on_button_down(mouse_btn); } } } Event::MouseButtonUp { mouse_btn, .. } => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - cap.on_button_up(mouse_btn, window.size()); + cap.on_button_up(mouse_btn); } } Event::MouseWheel { x, y, .. } => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - cap.on_wheel(x, y, window.size()); + cap.on_wheel(x, y); } } // Everything else (gamepad add/remove/button/axis/touchpad/sensor…) is @@ -402,13 +417,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } } pump.tick(); + // One coalesced MouseMove per iteration — pure motion must reach the host + // without waiting for a click/key to flush it. + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { + cap.flush_motion(); + } // Controller escape chord: release capture (+ leave fullscreen on desktop — under // a `--fullscreen` gamescope launch there is nothing to release into). Only // emitted while a session is attached. while escape_rx.try_recv().is_ok() { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - if cap.release() { + if cap.release(true) { + mouse.set_relative_mouse_mode(&window, false); mouse.show_cursor(true); } } @@ -422,6 +443,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = &mut stream { tracing::info!("controller chord: disconnect"); st.request_quit(); + mouse.set_relative_mouse_mode(&window, false); mouse.show_cursor(true); } } @@ -480,6 +502,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result st.clock_offset_ns = c.clock_offset_ns; let mut cap = Capture::new(c.clone()); cap.engage(); // capture engages when the stream starts (ui_stream parity) + mouse.set_relative_mouse_mode(&window, true); mouse.show_cursor(false); st.capture = Some(cap); st.connector = Some(c); @@ -503,6 +526,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result ModeCtl::Browse(_) => { tracing::warn!(%msg, "connect failed — back to the library"); stream = None; + mouse.set_relative_mouse_mode(&window, false); mouse.show_cursor(true); if let Some(o) = overlay.as_mut() { o.session_phase(SessionPhase::Failed(&msg)); @@ -513,8 +537,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result SessionEvent::Ended(reason) => { gamepad.detach(); if let Some(cap) = &mut st.capture { - cap.release(); + cap.release(true); } + mouse.set_relative_mouse_mode(&window, false); mouse.show_cursor(true); match &mode { ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)), diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 663167ee..b44a970b 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -149,7 +149,12 @@ pub fn default_backend() -> Backend { if c.eq_ignore_ascii_case("kwin") { return Backend::KwinFakeInput; } - if c.eq_ignore_ascii_case("wlroots") || c.eq_ignore_ascii_case("sway") { + if c.eq_ignore_ascii_case("wlroots") + || c.eq_ignore_ascii_case("sway") + // Hyprland kept the wlr virtual-input protocols, so it injects through the same + // backend as sway/river (design/hyprland-support.md D4). + || c.eq_ignore_ascii_case("hyprland") + { return Backend::WlrVirtual; } // mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below. diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 5ca5f61d..455eb73b 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -2657,7 +2657,7 @@ mod tests { let arr = body.as_array().expect("array"); // Every backend the host knows, in stable order. let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots"]); + assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]); for c in arr { assert!(c["available"].is_boolean()); assert!(c["default"].is_boolean()); diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 8a5d6b32..8c2ff379 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -2246,8 +2246,20 @@ fn pick_compositor( available: &[crate::vdisplay::Compositor], detected: Option, ) -> Option { - match crate::vdisplay::Compositor::from_pref(pref) { + use crate::vdisplay::Compositor; + match Compositor::from_pref(pref) { Some(want) if available.contains(&want) => Some(want), + // `CompositorPref::Wlroots` names the wlroots *family* (D2): sway/river ([`Wlroots`]) and + // Hyprland are distinct backends but mutually-exclusive live sessions, so honor the request + // with whichever family member is actually available — the detected one if it's a family + // member, else the first available of the two. + Some(Compositor::Wlroots) => match detected { + Some(d @ (Compositor::Wlroots | Compositor::Hyprland)) => Some(d), + _ => [Compositor::Wlroots, Compositor::Hyprland] + .into_iter() + .find(|c| available.contains(c)) + .or(detected), + }, _ => detected, } } @@ -4111,6 +4123,22 @@ mod tests { pick_compositor(CompositorPref::Gamescope, &[Gamescope], None), Some(Gamescope) ); + // Wlroots family (D2): the shared `Wlroots` pref resolves to whichever of sway/river + // (Wlroots) and Hyprland is the live session. + assert_eq!( + pick_compositor(CompositorPref::Wlroots, &[Hyprland], Some(Hyprland)), + Some(Hyprland) + ); + // …and to Wlroots-proper on a sway/river host. + assert_eq!( + pick_compositor(CompositorPref::Wlroots, &[Wlroots], Some(Wlroots)), + Some(Wlroots) + ); + // Family fallback even if detection came back empty but a member is available. + assert_eq!( + pick_compositor(CompositorPref::Wlroots, &[Hyprland], None), + Some(Hyprland) + ); } #[test] diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index f24cf76b..deed3c8f 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -956,12 +956,14 @@ pub fn detect() -> Result { if let Some(v) = crate::config::config().compositor.as_deref() { return match v.trim().to_ascii_lowercase().as_str() { "kwin" | "kde" | "plasma" => Ok(Compositor::Kwin), - "wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots), + // `hyprland` names the distinct backend (D1); `wlroots`/`sway`/`wlr` stay wlroots-proper. + "hyprland" | "hypr" => Ok(Compositor::Hyprland), + "wlroots" | "sway" | "wlr" | "river" => Ok(Compositor::Wlroots), "mutter" | "gnome" => Ok(Compositor::Mutter), "gamescope" => Ok(Compositor::Gamescope), other => { anyhow::bail!( - "unknown PUNKTFUNK_COMPOSITOR '{other}' (kwin|wlroots|mutter|gamescope)" + "unknown PUNKTFUNK_COMPOSITOR '{other}' (kwin|wlroots|hyprland|mutter|gamescope)" ) } }; @@ -977,10 +979,9 @@ pub fn detect() -> Result { Ok(Compositor::Kwin) } else if desktop.contains("GNOME") { Ok(Compositor::Mutter) - } else if desktop.contains("SWAY") - || desktop.contains("WLROOTS") - || desktop.contains("HYPRLAND") - { + } else if desktop.contains("HYPRLAND") { + Ok(Compositor::Hyprland) + } else if desktop.contains("SWAY") || desktop.contains("WLROOTS") { Ok(Compositor::Wlroots) } else { anyhow::bail!( @@ -999,6 +1000,7 @@ pub fn open(compositor: Compositor) -> Result> { Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)), Compositor::Mutter => Ok(Box::new(mutter::MutterDisplay::new()?)), Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)), + Compositor::Hyprland => Ok(Box::new(hyprland::HyprlandDisplay::new()?)), } } #[cfg(target_os = "windows")] @@ -1034,6 +1036,9 @@ pub fn probe(compositor: Compositor) -> Result<()> { { match compositor { Compositor::Kwin => kwin::probe(), + // Hyprland gets a real pre-flight: `hyprctl` must reach the compositor (else a clear + // error instead of a create-time failure), plus the permission-system warning. + Compositor::Hyprland => hyprland::probe(), // gamescope spawns its own nested session per `create`; Mutter is D-Bus on demand; // wlroots creates the output on demand — nothing to pre-check beyond "Linux". Compositor::Gamescope | Compositor::Mutter | Compositor::Wlroots => Ok(()), @@ -1189,6 +1194,9 @@ pub(crate) mod pf_vdisplay; #[cfg(target_os = "linux")] #[path = "vdisplay/linux/wlroots.rs"] mod wlroots; +#[cfg(target_os = "linux")] +#[path = "vdisplay/linux/hyprland.rs"] +mod hyprland; #[cfg(test)] mod tests { @@ -1212,6 +1220,10 @@ mod tests { compositor_for_kind(ActiveKind::DesktopWlroots), Some(Compositor::Wlroots) ); + assert_eq!( + compositor_for_kind(ActiveKind::DesktopHyprland), + Some(Compositor::Hyprland) + ); // No live session → no backend; the caller turns this into a handshake error / fallback. assert_eq!(compositor_for_kind(ActiveKind::None), None); } diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs index 56f31026..583972bd 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs @@ -987,7 +987,10 @@ fn do_restore_tv_session() { use super::ActiveKind; if matches!( super::detect_active_session().kind, - ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots + ActiveKind::DesktopKde + | ActiveKind::DesktopGnome + | ActiveKind::DesktopWlroots + | ActiveKind::DesktopHyprland ) { tracing::info!( "gamescope (SteamOS): a desktop session is active — removed the headless \ diff --git a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs new file mode 100644 index 00000000..fe6afed9 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs @@ -0,0 +1,540 @@ +//! Hyprland virtual-output backend via `hyprctl` IPC + the xdg ScreenCast portal +//! (xdg-desktop-portal-hyprland / xdph). See `design/hyprland-support.md`. +//! +//! Hyprland dropped wlroots in v0.42 (aquamarine backend) but kept the client-facing wlr +//! protocols, so it shares the wlr virtual-input path with sway — but it needs its own IPC and +//! portal, so it is a **distinct backend** from [`super::wlroots`], not a branch inside it (D1): +//! +//! 1. `hyprctl output create headless PF-` adds a named headless output — Hyprland supports +//! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll +//! `hyprctl -j monitors` until the name shows up. +//! 2. A monitor rule sets the client's exact mode. The grammar changed in 0.55 (hyprlang → +//! Lua), so [`set_monitor_rule`] tries both eras (D5): `hyprctl keyword monitor NAME,WxH@Hz,…` +//! (≤0.54, still loads on 0.55 during the deprecation window) and `hyprctl eval 'hl.monitor{…}'` +//! (≥0.55), ordered by the detected version and falling back to the other. +//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is +//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a +//! managed config (`~/.config/hypr/xdph.conf`) points `custom_picker_binary` at a tiny installed +//! shim that cats a per-session selection file we write (`screen:`) right before the +//! handshake — byte-for-byte the xdpw pattern, different picker wire format. +//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs +//! `hyprctl output remove NAME`. +//! +//! Requirements: the host runs inside (or can reach) the Hyprland session — either +//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from +//! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with +//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`). +//! +//! ⚠ **Spike-pending contract** (`design/hyprland-support.md` Phase 0): the xdph `custom_picker_binary` +//! stdout format and config path, and the `hl.monitor{}` Lua signature, are documented best-guesses +//! here. A wrong picker line degrades to the interactive picker (per the plan's risk table), and +//! `set_monitor_rule` tries both eras, so a wrong guess self-heals rather than breaking capture. + +use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput}; +use anyhow::{anyhow, bail, Context, Result}; +use std::os::fd::OwnedFd; +use std::process::Command; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Once, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Per-session file the xdph custom picker reads the selected output from. We write +/// `screen:\n` here right before the portal handshake selects sources. Lives under +/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could +/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the +/// wlroots chooser file. +fn selection_file() -> String { + let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + format!("{dir}/punktfunk-xdph-output") +} + +/// The installed custom-picker shim: a tiny script that cats [`selection_file`]. xdph runs +/// `custom_picker_binary` and reads one selection line from its stdout; an empty read (no session +/// has written the file) leaves xdph to its interactive picker — the graceful fallback. +fn picker_shim_path() -> String { + let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + format!("{dir}/punktfunk-xdph-picker.sh") +} + +/// The picker line for output `name`. ⚠ Spike-pending: xdph's custom-picker stdout contract is +/// `screen:` (vs `window:` / `region:…`) to the best of current knowledge — this +/// is the single place to correct once the Phase-0 spike pins it. A wrong value just means xdph +/// falls back to its interactive picker (degraded, not broken). +fn picker_selection_line(name: &str) -> String { + format!("screen:{name}\n") +} + +/// The managed xdph config: point the screencopy custom picker at our shim so headless output +/// selection needs no GUI. xdph reads its config at startup, so a change restarts it (see +/// [`ensure_xdph_config`]). The *selection* is the per-session file, not this static config. +fn xdph_config() -> String { + format!( + "# managed by punktfunk (vdisplay/hyprland.rs) — headless per-session output selection.\n\ +screencopy {{\n\ + custom_picker_binary = {}\n\ +}}\n", + picker_shim_path() + ) +} + +/// Monotonic per-process counter for headless output names (`PF-1`, `PF-2`, …). Named outputs kill +/// the before/after diff race sway needs (D6). +static OUTPUT_SEQ: AtomicU32 = AtomicU32::new(0); + +fn next_output_name() -> String { + format!("PF-{}", OUTPUT_SEQ.fetch_add(1, Ordering::Relaxed) + 1) +} + +/// 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; + +impl HyprlandDisplay { + pub fn new() -> Result { + Ok(HyprlandDisplay) + } +} + +/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by +/// `HYPRLAND_INSTANCE_SIGNATURE` (inherited from the session) **or** a discoverable instance socket +/// under `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env +/// import, unlike sway's `SWAYSOCK`; the signature is then exported by `apply_session_env`). Cheap, +/// side-effect-free — safe on the enumeration path. +pub fn is_available() -> bool { + if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() { + return true; + } + let dir = match std::env::var_os("XDG_RUNTIME_DIR") { + Some(d) => std::path::PathBuf::from(d).join("hypr"), + None => return false, + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries + .flatten() + .any(|e| e.path().join(".socket.sock").exists()) +} + +/// Pre-flight for the Hyprland backend: `hyprctl` must reach the compositor (a clear error now +/// beats a create-time failure), and if the permission system is enforcing, warn about the silent +/// black-frame / dropped-input failure mode. +pub fn probe() -> Result<()> { + hyprctl(&["-j", "version"]).context( + "hyprctl not reachable — is Hyprland running and HYPRLAND_INSTANCE_SIGNATURE set? (the \ + host must run inside, or be able to reach, the Hyprland session)", + )?; + warn_if_permissions_enforced(); + Ok(()) +} + +impl VirtualDisplay for HyprlandDisplay { + fn name(&self) -> &'static str { + "hyprland" + } + + fn create(&mut self, mode: Mode) -> Result { + // Log the permission-system caveat once per process (silent black frames otherwise). + preflight_once(); + + let name = next_output_name(); + hyprctl_dispatch(&["output", "create", "headless", &name]) + .with_context(|| format!("hyprctl output create headless {name} (is hyprctl reachable?)"))?; + // Own the output from here on so any later error (or drop) removes it. + let output = OutputGuard(name.clone()); + wait_monitor_ready(&name, Duration::from_secs(5)) + .with_context(|| format!("waiting for headless output {name} to appear"))?; + + // The client's exact mode (also the frame clock — a headless output is timer-paced from it). + set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?; + + // Steer xdph's custom picker at our new output, then run the portal handshake on its own + // thread (it parks to keep the cast alive, like the other backends). + ensure_xdph_config()?; + let sel = selection_file(); + std::fs::write(&sel, picker_selection_line(&name)) + .with_context(|| format!("write {sel}"))?; + + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + thread::Builder::new() + .name("punktfunk-hypr-vout".into()) + .spawn(move || portal_thread(setup_tx, stop_thread)) + .context("spawn hyprland portal thread")?; + + let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok(v)) => v, + Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"), + Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"), + }; + tracing::info!( + node_id, + output = %name, + w = mode.width, + h = mode.height, + hz = mode.refresh_hz, + "hyprland headless output ready" + ); + Ok(VirtualOutput { + node_id, + remote_fd: Some(fd), + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), + keepalive: Box::new(Keepalive { + _stop: StopGuard(stop), + _output: output, + }), + // Owned (the compositor output is ours to tear down), but not registry-poolable: the + // portal fd can't be re-opened per attach, so the registry passes it through on + // `remote_fd.is_some()` — same as wlroots. + ownership: DisplayOwnership::Owned, + reused_gen: None, + }) + } +} + +/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then +/// remove the output (fields drop in declaration order). +struct Keepalive { + _stop: StopGuard, + _output: OutputGuard, +} + +/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then +/// tears the screencast session down. +struct StopGuard(Arc); + +impl Drop for StopGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// Owns the created headless output; dropping it removes it from Hyprland. +struct OutputGuard(String); + +impl Drop for OutputGuard { + fn drop(&mut self) { + match hyprctl_dispatch(&["output", "remove", &self.0]) { + Ok(_) => tracing::info!(output = %self.0, "hyprland headless output removed"), + Err(e) => { + tracing::warn!(output = %self.0, error = %format!("{e:#}"), "output remove failed") + } + } + } +} + +/// Run `hyprctl `, returning stdout. `hyprctl` reads `HYPRLAND_INSTANCE_SIGNATURE` from the +/// env (exported by `apply_session_env`) to reach the right instance socket. It exits non-zero on a +/// hard failure, but for dispatch commands it can print an error with status 0 — see +/// [`hyprctl_dispatch`]. +fn hyprctl(args: &[&str]) -> Result { + let out = Command::new("hyprctl") + .args(args) + .output() + .context("run hyprctl (is Hyprland installed?)")?; + if !out.status.success() { + bail!( + "hyprctl {:?} failed: {}{}", + args, + String::from_utf8_lossy(&out.stdout).trim(), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by +/// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to +/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the +/// two config eras apart). +fn hyprctl_dispatch(args: &[&str]) -> Result<()> { + let out = hyprctl(args)?; + let t = out.trim(); + let lc = t.to_ascii_lowercase(); + if lc.contains("invalid") + || lc.contains("not found") + || lc.contains("couldn't") + || lc.contains("could not") + || lc.contains("unknown") + || lc.contains("no such") + || lc.contains("error") + { + bail!("hyprctl {:?} rejected: {t}", args); + } + Ok(()) +} + +/// Wait until the named headless output shows up in `hyprctl -j monitors` (it appears near-instantly +/// in practice; poll briefly to be safe). +fn wait_monitor_ready(name: &str, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + if monitor_exists(name)? { + return Ok(()); + } + if Instant::now() >= deadline { + bail!("output create succeeded but monitor {name} never appeared"); + } + thread::sleep(Duration::from_millis(50)); + } +} + +/// Is a monitor named `name` present in `hyprctl -j monitors` (JSON)? +fn monitor_exists(name: &str) -> Result { + let out = hyprctl(&["-j", "monitors"])?; + let monitors: serde_json::Value = + serde_json::from_str(&out).context("parse hyprctl -j monitors")?; + Ok(monitors + .as_array() + .map(|a| { + a.iter() + .any(|m| m.get("name").and_then(|n| n.as_str()) == Some(name)) + }) + .unwrap_or(false)) +} + +/// Set the client's exact mode on `name`, supporting both config eras (D5). Encapsulates the whole +/// era split: ≤0.54 uses `hyprctl keyword monitor NAME,WxH@Hz,auto,1`; ≥0.55 (hyprlang → Lua) +/// prefers `hyprctl eval 'hl.monitor{…}'`. Whichever era we detect, we try its form first and fall +/// back to the other — hyprlang configs still load during the 0.55 deprecation window, so `keyword` +/// keeps working for a while, and the fallback makes a wrong version guess self-heal. +fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> { + let hz = mode.refresh_hz.max(1); + let spec = format!("{name},{}x{}@{hz},auto,1", mode.width, mode.height); + let lua = format!( + "hl.monitor{{ output = \"{name}\", mode = \"{}x{}@{hz}\", position = \"auto\", scale = 1 }}", + mode.width, mode.height + ); + let keyword: Vec<&str> = vec!["keyword", "monitor", &spec]; + let eval: Vec<&str> = vec!["eval", &lua]; + let attempts: [&Vec<&str>; 2] = if hyprland_version_at_least(0, 55) { + [&eval, &keyword] + } else { + [&keyword, &eval] + }; + let mut errs = Vec::new(); + for a in attempts { + match hyprctl_dispatch(a) { + Ok(()) => return Ok(()), + Err(e) => errs.push(format!("{e:#}")), + } + } + bail!( + "hyprctl monitor rule failed on both config eras: {}", + errs.join(" | ") + ) +} + +/// 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. +fn hyprland_version() -> Option<(u16, u16, u16)> { + static V: OnceLock> = OnceLock::new(); + *V.get_or_init(|| { + let out = hyprctl(&["-j", "version"]).ok()?; + let json: serde_json::Value = serde_json::from_str(&out).ok()?; + parse_version_tag(json.get("tag").and_then(|t| t.as_str())?) + }) +} + +/// Parse a Hyprland `tag` (`v0.55.0`, or a dev `v0.41.2-13-gabcdef`) to `(major, minor, patch)`. +fn parse_version_tag(tag: &str) -> Option<(u16, u16, u16)> { + let t = tag.trim().trim_start_matches(['v', 'V']); + let mut it = t.split(['.', '-', '_', '+']); + let major = it.next()?.parse().ok()?; + let minor = it.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let patch = it.next().and_then(|s| s.parse().ok()).unwrap_or(0); + Some((major, minor, patch)) +} + +/// Is the running Hyprland at least `major.minor`? Unknown version → `false` (assume the older +/// `keyword` era, which the fallback covers anyway). +fn hyprland_version_at_least(major: u16, minor: u16) -> bool { + matches!(hyprland_version(), Some((a, b, _)) if (a, b) >= (major, minor)) +} + +/// Log the permission-system caveat at most once per process: with +/// `ecosystem.enforce_permissions = true` (0.49+, off by default), direct screencopy/virtual-input +/// clients can be denied — and denial is **silent black frames / dropped input**, not an error. +fn preflight_once() { + static WARNED: Once = Once::new(); + WARNED.call_once(warn_if_permissions_enforced); +} + +fn warn_if_permissions_enforced() { + let Ok(out) = hyprctl(&["-j", "getoption", "ecosystem:enforce_permissions"]) else { + return; + }; + let on = serde_json::from_str::(&out) + .ok() + .and_then(|j| j.get("int").and_then(|v| v.as_i64())) + .is_some_and(|v| v != 0); + if on { + tracing::warn!( + "Hyprland ecosystem.enforce_permissions is ON — screencopy/virtual-input may be denied \ + as SILENT black frames / dropped input. Grant the host with hl.permission rules \ + (screencopy + virtual pointer/keyboard) — see docs/hyprland." + ); + } +} + +/// Make sure xdph uses our custom picker: install the shim (once) and write the managed config, +/// restarting xdph if the config changed (it reads config only at startup). Mirrors the wlroots +/// `ensure_xdpw_config` pattern. +fn ensure_xdph_config() -> Result<()> { + // 1. Install the picker shim (idempotent — content is fixed). + let shim = picker_shim_path(); + let sel = selection_file(); + let shim_body = format!("#!/bin/sh\nexec cat \"{sel}\" 2>/dev/null\n"); + if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) { + // already installed + } else { + std::fs::write(&shim, &shim_body).with_context(|| format!("write {shim}"))?; + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("chmod {shim}"))?; + } + } + + // 2. Write the managed xdph config and restart xdph on change. + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?; + let dir = base.join("hypr"); + let path = dir.join("xdph.conf"); + let cfg = xdph_config(); + if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) { + return Ok(()); + } + std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; + std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?; + tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-hyprland config"); + let _ = Command::new("systemctl") + .args([ + "--user", + "try-restart", + "xdg-desktop-portal-hyprland.service", + ]) + .status(); + Ok(()) +} + +/// The ScreenCast portal handshake — the xdg ScreenCast portal is backend-neutral (served here by +/// xdph), so this mirrors the wlroots portal thread: it reports the fd + node id and parks until +/// 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) { + use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType}; + use ashpd::desktop::PersistMode; + use ashpd::enumflags2::BitFlags; + + // Multi-thread runtime: the zbus background reader must be pumped across the + // create_session → select_sources → start handshake (see capture/linux.rs). + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = setup_tx.send(Err(format!("build tokio runtime: {e}"))); + return; + } + }; + let err_tx = setup_tx.clone(); + + rt.block_on(async move { + let result: Result<()> = async { + let proxy = Screencast::new().await.context( + "connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)", + )?; + let session = proxy + .create_session(Default::default()) + .await + .context("create_session")?; + proxy + .select_sources( + &session, + SelectSourcesOptions::default() + .set_cursor_mode(CursorMode::Embedded) + // xdph offers MONITOR; the custom picker selects our output. + .set_sources(BitFlags::from_flag(SourceType::Monitor)) + .set_multiple(false) + .set_persist_mode(PersistMode::DoNot), + ) + .await + .context("select_sources")? + .response() + .context("select_sources rejected")?; + let streams = proxy + .start(&session, None, Default::default()) + .await + .context("start cast")? + .response() + .context("start response (custom picker declined? check the xdph config/shim/selection file)")?; + let stream = streams + .streams() + .first() + .context("portal returned no streams")? + .clone(); + let node_id = stream.pipe_wire_node_id(); + let fd = proxy + .open_pipe_wire_remote(&session, Default::default()) + .await + .context("open_pipe_wire_remote")?; + + setup_tx + .send(Ok((fd, node_id))) + .map_err(|_| anyhow!("virtual-output opener went away"))?; + + // Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast + // is torn down when the connection drops. + let _keep_alive = (&proxy, &session); + while !stop.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_millis(200)).await; + } + Ok(()) + } + .await; + + if let Err(e) = result { + let _ = err_tx.send(Err(format!("{e:#}"))); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_tag_parses_release_and_dev_builds() { + assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0))); + assert_eq!(parse_version_tag("0.41.2"), Some((0, 41, 2))); + // Dev builds tack the commit distance + hash on with a dash. + assert_eq!(parse_version_tag("v0.41.2-13-gabcdef"), Some((0, 41, 2))); + // Missing patch defaults to 0; garbage is rejected. + assert_eq!(parse_version_tag("v1.0"), Some((1, 0, 0))); + assert_eq!(parse_version_tag("wat"), None); + } + + #[test] + fn output_names_are_unique_and_prefixed() { + let a = next_output_name(); + let b = next_output_name(); + assert!(a.starts_with("PF-") && b.starts_with("PF-")); + assert_ne!(a, b); + } + + #[test] + fn picker_line_is_screen_scoped() { + assert_eq!(picker_selection_line("PF-1"), "screen:PF-1\n"); + } +}