fix(input): pointer lock + the missing per-iteration motion flush
Two capture bugs compounding into 'sluggish and not captured': 1. flush_motion was never called from the run loop — the GTK client's frame-clock tick flushed pending motion every frame, and the port lost it. Pure mouse movement only reached the host when a click/key/ scroll happened to flush it; the host cursor simply didn't follow. Now one coalesced MouseMove per loop iteration. 2. Capture forwarded ABSOLUTE letterboxed coordinates with no pointer confinement (GTK parity, the plan's deferred 'stage-2' item): the visible local cursor outran the host cursor by the full e2e and escaped the window. Capture is now real pointer lock — SDL relative mouse mode (hidden, confined, raw deltas) with relative MouseMove on the wire, so the host cursor is the only cursor. Also: focus-gain re-engages after an auto-release (Alt-Tab undoes itself; the startup focus race can no longer strand the session uncaptured) while a chord release stays released until the user opts back in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
//! 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
|
//! 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
|
//! 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
|
//! flushed host-side on release so nothing sticks down. While captured the pointer is
|
||||||
//! is hidden (the host renders its own); while released nothing is forwarded.
|
//! 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
|
//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are
|
||||||
//! (`MouseMoveAbs` scaled into the negotiated mode through the letterbox transform,
|
//! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would
|
||||||
//! window size packed in `flags` — the GTK client's exact contract); motion is coalesced
|
//! otherwise send a datagram per event).
|
||||||
//! to one datagram per loop iteration. Pointer-lock relative capture is the follow-up
|
|
||||||
//! (§5.3 of the plan) — absolute first for GTK parity.
|
|
||||||
|
|
||||||
use crate::keymap_sdl;
|
use crate::keymap_sdl;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
@@ -21,13 +24,13 @@ use std::sync::Arc;
|
|||||||
pub struct Capture {
|
pub struct Capture {
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
captured: bool,
|
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.
|
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||||
held_keys: HashSet<u8>,
|
held_keys: HashSet<u8>,
|
||||||
held_buttons: HashSet<u32>,
|
held_buttons: HashSet<u32>,
|
||||||
/// Newest absolute pointer position (window coordinates) not yet on the wire —
|
/// Relative motion not yet on the wire, summed per loop iteration.
|
||||||
/// motion events only store here; `flush_motion` sends at most one `MouseMoveAbs`
|
pending_rel: (i32, i32),
|
||||||
/// per loop iteration (a 1000 Hz mouse would otherwise send a datagram per event).
|
|
||||||
pending_abs: Option<(f32, f32)>,
|
|
||||||
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
|
/// 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.
|
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
|
||||||
scroll_acc: (f64, f64),
|
scroll_acc: (f64, f64),
|
||||||
@@ -49,9 +52,10 @@ impl Capture {
|
|||||||
Capture {
|
Capture {
|
||||||
connector,
|
connector,
|
||||||
captured: false,
|
captured: false,
|
||||||
|
user_released: false,
|
||||||
held_keys: HashSet::new(),
|
held_keys: HashSet::new(),
|
||||||
held_buttons: HashSet::new(),
|
held_buttons: HashSet::new(),
|
||||||
pending_abs: None,
|
pending_rel: (0, 0),
|
||||||
scroll_acc: (0.0, 0.0),
|
scroll_acc: (0.0, 0.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,18 +64,29 @@ impl Capture {
|
|||||||
self.captured
|
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 {
|
pub fn engage(&mut self) -> bool {
|
||||||
|
self.user_released = false;
|
||||||
!std::mem::replace(&mut self.captured, true)
|
!std::mem::replace(&mut self.captured, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release capture, flushing everything held so nothing sticks down on the host.
|
/// Release capture, flushing everything held so nothing sticks down on the host.
|
||||||
/// The caller restores the cursor. Returns false if it wasn't engaged.
|
/// `by_user` = the chord (stays released); focus loss re-engages on focus gain.
|
||||||
pub fn release(&mut self) -> bool {
|
/// 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) {
|
if !std::mem::replace(&mut self.captured, false) {
|
||||||
return 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() {
|
for vk in self.held_keys.drain() {
|
||||||
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
|
||||||
}
|
}
|
||||||
@@ -81,44 +96,30 @@ impl Capture {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forward the coalesced pointer position, if any — one datagram per loop iteration.
|
/// Forward the coalesced motion delta, if any — one datagram per loop iteration.
|
||||||
/// `win` is the window's logical size (SDL mouse coordinates' space).
|
pub fn flush_motion(&mut self) {
|
||||||
pub fn flush_motion(&mut self, win: (u32, u32)) {
|
let (dx, dy) = std::mem::take(&mut self.pending_rel);
|
||||||
if let Some((x, y)) = self.pending_abs.take() {
|
if dx != 0 || dy != 0 {
|
||||||
self.send_abs(x, y, win);
|
send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute position: window coordinates → video pixels through the Contain-fit
|
/// Relative motion (SDL relative mouse mode delivers raw deltas while locked).
|
||||||
/// letterbox (the blit uses the same math in pixel space — the ratios are identical).
|
pub fn on_motion(&mut self, xrel: f32, yrel: f32) {
|
||||||
/// `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) {
|
|
||||||
if self.captured {
|
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 {
|
if !self.captured {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
|
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
|
||||||
// Keep the wire ordered: the host must see the cursor where the user does
|
// 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").
|
// when the key lands (e.g. "press E at the crosshair").
|
||||||
self.flush_motion(win);
|
self.flush_motion();
|
||||||
self.held_keys.insert(vk);
|
self.held_keys.insert(vk);
|
||||||
send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
|
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
|
/// 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
|
/// never reaches here). Pending motion flushes first so the button-down lands where
|
||||||
/// button-down lands exactly where the cursor last was.
|
/// the host cursor actually is.
|
||||||
pub fn on_button_down(&mut self, b: sdl3::mouse::MouseButton, x: f32, y: f32, win: (u32, u32)) {
|
pub fn on_button_down(&mut self, b: sdl3::mouse::MouseButton) {
|
||||||
if !self.captured {
|
if !self.captured {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.pending_abs = Some((x, y));
|
self.flush_motion();
|
||||||
self.flush_motion(win);
|
|
||||||
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
||||||
self.held_buttons.insert(gs);
|
self.held_buttons.insert(gs);
|
||||||
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_button_up(&mut self, b: sdl3::mouse::MouseButton, win: (u32, u32)) {
|
pub fn on_button_up(&mut self, b: sdl3::mouse::MouseButton) {
|
||||||
self.flush_motion(win); // the release must not beat the motion before it
|
self.flush_motion(); // the release must not beat the motion before it
|
||||||
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
|
||||||
if self.held_buttons.remove(&gs) {
|
if self.held_buttons.remove(&gs) {
|
||||||
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
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
|
/// 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
|
/// is positive = up and x positive = right already. Fractional remainders
|
||||||
/// inverted one). Fractional remainders accumulate per axis.
|
/// accumulate per axis.
|
||||||
pub fn on_wheel(&mut self, dx: f32, dy: f32, win: (u32, u32)) {
|
pub fn on_wheel(&mut self, dx: f32, dy: f32) {
|
||||||
if !self.captured {
|
if !self.captured {
|
||||||
return;
|
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;
|
let (mut ax, mut ay) = self.scroll_acc;
|
||||||
ay += f64::from(dy) * 120.0;
|
ay += f64::from(dy) * 120.0;
|
||||||
ax += f64::from(dx) * 120.0;
|
ax += f64::from(dx) * 120.0;
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ impl StreamState {
|
|||||||
/// The pump then emits `Ended(None)` — the loop's normal end path picks it up.
|
/// The pump then emits `Ended(None)` — the loop's normal end path picks it up.
|
||||||
fn request_quit(&mut self) {
|
fn request_quit(&mut self) {
|
||||||
if let Some(cap) = &mut self.capture {
|
if let Some(cap) = &mut self.capture {
|
||||||
cap.release();
|
cap.release(true);
|
||||||
}
|
}
|
||||||
if let Some(c) = &self.connector {
|
if let Some(c) = &self.connector {
|
||||||
c.disconnect_quit();
|
c.disconnect_quit();
|
||||||
@@ -299,12 +299,25 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
Event::Window { win_event, .. } => match win_event {
|
Event::Window { win_event, .. } => match win_event {
|
||||||
WindowEvent::FocusLost => {
|
WindowEvent::FocusLost => {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
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);
|
mouse.show_cursor(true);
|
||||||
tracing::info!("focus lost — input released");
|
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(..) => {
|
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
|
||||||
presenter.recreate_swapchain(&window)?;
|
presenter.recreate_swapchain(&window)?;
|
||||||
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
|
||||||
@@ -327,10 +340,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if chord && sc == Scancode::Q {
|
if chord && sc == Scancode::Q {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if cap.captured() {
|
if cap.captured() {
|
||||||
cap.release();
|
cap.release(true);
|
||||||
|
mouse.set_relative_mouse_mode(&window, false);
|
||||||
mouse.show_cursor(true);
|
mouse.show_cursor(true);
|
||||||
} else {
|
} else {
|
||||||
cap.engage();
|
cap.engage();
|
||||||
|
mouse.set_relative_mouse_mode(&window, true);
|
||||||
mouse.show_cursor(false);
|
mouse.show_cursor(false);
|
||||||
}
|
}
|
||||||
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
tracing::info!(captured = cap.captured(), "chord: release/engage");
|
||||||
@@ -341,6 +356,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(st) = &mut stream {
|
if let Some(st) = &mut stream {
|
||||||
tracing::info!("chord: disconnect");
|
tracing::info!("chord: disconnect");
|
||||||
st.request_quit();
|
st.request_quit();
|
||||||
|
mouse.set_relative_mouse_mode(&window, false);
|
||||||
mouse.show_cursor(true);
|
mouse.show_cursor(true);
|
||||||
// The pump emits Ended(None); the end path routes per mode.
|
// 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<Option<Outcome>
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
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 {
|
Event::KeyUp {
|
||||||
@@ -368,32 +384,31 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
cap.on_key_up(sc);
|
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()) {
|
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 {
|
Event::MouseButtonDown { mouse_btn, .. } => {
|
||||||
mouse_btn, x, y, ..
|
|
||||||
} => {
|
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
||||||
if !cap.captured() {
|
if !cap.captured() {
|
||||||
// The engaging click is suppressed toward the host.
|
// The engaging click is suppressed toward the host.
|
||||||
cap.engage();
|
cap.engage();
|
||||||
|
mouse.set_relative_mouse_mode(&window, true);
|
||||||
mouse.show_cursor(false);
|
mouse.show_cursor(false);
|
||||||
} else {
|
} else {
|
||||||
cap.on_button_down(mouse_btn, x, y, window.size());
|
cap.on_button_down(mouse_btn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::MouseButtonUp { mouse_btn, .. } => {
|
Event::MouseButtonUp { mouse_btn, .. } => {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
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, .. } => {
|
Event::MouseWheel { x, y, .. } => {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
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
|
// Everything else (gamepad add/remove/button/axis/touchpad/sensor…) is
|
||||||
@@ -402,13 +417,19 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pump.tick();
|
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
|
// Controller escape chord: release capture (+ leave fullscreen on desktop — under
|
||||||
// a `--fullscreen` gamescope launch there is nothing to release into). Only
|
// a `--fullscreen` gamescope launch there is nothing to release into). Only
|
||||||
// emitted while a session is attached.
|
// emitted while a session is attached.
|
||||||
while escape_rx.try_recv().is_ok() {
|
while escape_rx.try_recv().is_ok() {
|
||||||
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
|
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);
|
mouse.show_cursor(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,6 +443,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
if let Some(st) = &mut stream {
|
if let Some(st) = &mut stream {
|
||||||
tracing::info!("controller chord: disconnect");
|
tracing::info!("controller chord: disconnect");
|
||||||
st.request_quit();
|
st.request_quit();
|
||||||
|
mouse.set_relative_mouse_mode(&window, false);
|
||||||
mouse.show_cursor(true);
|
mouse.show_cursor(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -480,6 +502,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
st.clock_offset_ns = c.clock_offset_ns;
|
st.clock_offset_ns = c.clock_offset_ns;
|
||||||
let mut cap = Capture::new(c.clone());
|
let mut cap = Capture::new(c.clone());
|
||||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||||
|
mouse.set_relative_mouse_mode(&window, true);
|
||||||
mouse.show_cursor(false);
|
mouse.show_cursor(false);
|
||||||
st.capture = Some(cap);
|
st.capture = Some(cap);
|
||||||
st.connector = Some(c);
|
st.connector = Some(c);
|
||||||
@@ -503,6 +526,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
ModeCtl::Browse(_) => {
|
ModeCtl::Browse(_) => {
|
||||||
tracing::warn!(%msg, "connect failed — back to the library");
|
tracing::warn!(%msg, "connect failed — back to the library");
|
||||||
stream = None;
|
stream = None;
|
||||||
|
mouse.set_relative_mouse_mode(&window, false);
|
||||||
mouse.show_cursor(true);
|
mouse.show_cursor(true);
|
||||||
if let Some(o) = overlay.as_mut() {
|
if let Some(o) = overlay.as_mut() {
|
||||||
o.session_phase(SessionPhase::Failed(&msg));
|
o.session_phase(SessionPhase::Failed(&msg));
|
||||||
@@ -513,8 +537,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
SessionEvent::Ended(reason) => {
|
SessionEvent::Ended(reason) => {
|
||||||
gamepad.detach();
|
gamepad.detach();
|
||||||
if let Some(cap) = &mut st.capture {
|
if let Some(cap) = &mut st.capture {
|
||||||
cap.release();
|
cap.release(true);
|
||||||
}
|
}
|
||||||
|
mouse.set_relative_mouse_mode(&window, false);
|
||||||
mouse.show_cursor(true);
|
mouse.show_cursor(true);
|
||||||
match &mode {
|
match &mode {
|
||||||
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)),
|
||||||
|
|||||||
@@ -149,7 +149,12 @@ pub fn default_backend() -> Backend {
|
|||||||
if c.eq_ignore_ascii_case("kwin") {
|
if c.eq_ignore_ascii_case("kwin") {
|
||||||
return Backend::KwinFakeInput;
|
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;
|
return Backend::WlrVirtual;
|
||||||
}
|
}
|
||||||
// mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
|
// mutter (GNOME) falls through to the XDG_CURRENT_DESKTOP check below.
|
||||||
|
|||||||
@@ -2657,7 +2657,7 @@ mod tests {
|
|||||||
let arr = body.as_array().expect("array");
|
let arr = body.as_array().expect("array");
|
||||||
// Every backend the host knows, in stable order.
|
// Every backend the host knows, in stable order.
|
||||||
let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect();
|
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 {
|
for c in arr {
|
||||||
assert!(c["available"].is_boolean());
|
assert!(c["available"].is_boolean());
|
||||||
assert!(c["default"].is_boolean());
|
assert!(c["default"].is_boolean());
|
||||||
|
|||||||
@@ -2246,8 +2246,20 @@ fn pick_compositor(
|
|||||||
available: &[crate::vdisplay::Compositor],
|
available: &[crate::vdisplay::Compositor],
|
||||||
detected: Option<crate::vdisplay::Compositor>,
|
detected: Option<crate::vdisplay::Compositor>,
|
||||||
) -> Option<crate::vdisplay::Compositor> {
|
) -> Option<crate::vdisplay::Compositor> {
|
||||||
match crate::vdisplay::Compositor::from_pref(pref) {
|
use crate::vdisplay::Compositor;
|
||||||
|
match Compositor::from_pref(pref) {
|
||||||
Some(want) if available.contains(&want) => Some(want),
|
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,
|
_ => detected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4111,6 +4123,22 @@ mod tests {
|
|||||||
pick_compositor(CompositorPref::Gamescope, &[Gamescope], None),
|
pick_compositor(CompositorPref::Gamescope, &[Gamescope], None),
|
||||||
Some(Gamescope)
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -956,12 +956,14 @@ pub fn detect() -> Result<Compositor> {
|
|||||||
if let Some(v) = crate::config::config().compositor.as_deref() {
|
if let Some(v) = crate::config::config().compositor.as_deref() {
|
||||||
return match v.trim().to_ascii_lowercase().as_str() {
|
return match v.trim().to_ascii_lowercase().as_str() {
|
||||||
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
|
"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),
|
"mutter" | "gnome" => Ok(Compositor::Mutter),
|
||||||
"gamescope" => Ok(Compositor::Gamescope),
|
"gamescope" => Ok(Compositor::Gamescope),
|
||||||
other => {
|
other => {
|
||||||
anyhow::bail!(
|
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<Compositor> {
|
|||||||
Ok(Compositor::Kwin)
|
Ok(Compositor::Kwin)
|
||||||
} else if desktop.contains("GNOME") {
|
} else if desktop.contains("GNOME") {
|
||||||
Ok(Compositor::Mutter)
|
Ok(Compositor::Mutter)
|
||||||
} else if desktop.contains("SWAY")
|
} else if desktop.contains("HYPRLAND") {
|
||||||
|| desktop.contains("WLROOTS")
|
Ok(Compositor::Hyprland)
|
||||||
|| desktop.contains("HYPRLAND")
|
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||||
{
|
|
||||||
Ok(Compositor::Wlroots)
|
Ok(Compositor::Wlroots)
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@@ -999,6 +1000,7 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
|||||||
Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)),
|
Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)),
|
||||||
Compositor::Mutter => Ok(Box::new(mutter::MutterDisplay::new()?)),
|
Compositor::Mutter => Ok(Box::new(mutter::MutterDisplay::new()?)),
|
||||||
Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)),
|
Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)),
|
||||||
|
Compositor::Hyprland => Ok(Box::new(hyprland::HyprlandDisplay::new()?)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -1034,6 +1036,9 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
|||||||
{
|
{
|
||||||
match compositor {
|
match compositor {
|
||||||
Compositor::Kwin => kwin::probe(),
|
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;
|
// 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".
|
// wlroots creates the output on demand — nothing to pre-check beyond "Linux".
|
||||||
Compositor::Gamescope | Compositor::Mutter | Compositor::Wlroots => Ok(()),
|
Compositor::Gamescope | Compositor::Mutter | Compositor::Wlroots => Ok(()),
|
||||||
@@ -1189,6 +1194,9 @@ pub(crate) mod pf_vdisplay;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "vdisplay/linux/wlroots.rs"]
|
#[path = "vdisplay/linux/wlroots.rs"]
|
||||||
mod wlroots;
|
mod wlroots;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[path = "vdisplay/linux/hyprland.rs"]
|
||||||
|
mod hyprland;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -1212,6 +1220,10 @@ mod tests {
|
|||||||
compositor_for_kind(ActiveKind::DesktopWlroots),
|
compositor_for_kind(ActiveKind::DesktopWlroots),
|
||||||
Some(Compositor::Wlroots)
|
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.
|
// No live session → no backend; the caller turns this into a handshake error / fallback.
|
||||||
assert_eq!(compositor_for_kind(ActiveKind::None), None);
|
assert_eq!(compositor_for_kind(ActiveKind::None), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -987,7 +987,10 @@ fn do_restore_tv_session() {
|
|||||||
use super::ActiveKind;
|
use super::ActiveKind;
|
||||||
if matches!(
|
if matches!(
|
||||||
super::detect_active_session().kind,
|
super::detect_active_session().kind,
|
||||||
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
|
ActiveKind::DesktopKde
|
||||||
|
| ActiveKind::DesktopGnome
|
||||||
|
| ActiveKind::DesktopWlroots
|
||||||
|
| ActiveKind::DesktopHyprland
|
||||||
) {
|
) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"gamescope (SteamOS): a desktop session is active — removed the headless \
|
"gamescope (SteamOS): a desktop session is active — removed the headless \
|
||||||
|
|||||||
@@ -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-<n>` 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:<NAME>`) 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:<NAME>\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:<output-name>` (vs `window:<addr>` / `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<Self> {
|
||||||
|
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<VirtualOutput> {
|
||||||
|
// 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::<Result<(OwnedFd, u32), String>>();
|
||||||
|
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<AtomicBool>);
|
||||||
|
|
||||||
|
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 <args>`, 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<String> {
|
||||||
|
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<bool> {
|
||||||
|
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<Option<(u16, u16, u16)>> = 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::<serde_json::Value>(&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<Result<(OwnedFd, u32), String>>, stop: Arc<AtomicBool>) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user