feat(vdisplay): mirror a pinned monitor on sway and Hyprland
P5 of design/per-monitor-portal-capture.md, and the end of a hole worth naming: the pin was accepted, persisted and offered in the console while `create` bailed on these two backends, so a sway or Hyprland host with a monitor selected failed EVERY session. Both already ship the mechanism — a managed portal-chooser config plus a per-session selection file (xdpw's `Monitor: NAME`, xdph's `[SELECTION]screen:NAME`) — so mirroring is that same chooser pointed at a physical connector instead of a headless output we created. The keepalive stops the cast and nothing else; the monitor is the compositor's. The selection write is now serialized against the handshake that reads it (SELECTION_LOCK, applied to the virtual-output paths too). That file is one per-user path: whoever writes last before the portal reads wins, and the loser does not fail — it silently captures the other session's output. `managed` is no longer grounds for refusal everywhere. It is conclusive only where the name is ours by construction (KWin's `Virtual-punktfunk`, Hyprland's `PF-N`); sway names EVERY headless output `HEADLESS-N`, its own included, so the old rule would have refused to mirror any output on a headless sway box — exactly the remote setup this feature serves. Found on-glass, and now a test. Verified on a two-output headless sway: asking for HEADLESS-1 yields 1280x720 frames and HEADLESS-2 yields 1920x1080, so the chooser really does steer per request. Hyprland is compile-only — no Hyprland box exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,25 +174,12 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
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();
|
||||
let hw_cursor = self.hw_cursor;
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
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}"),
|
||||
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
|
||||
// selection is one per-user file, so a concurrent session's write between ours and xdph's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -272,6 +259,49 @@ fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Serializes **write-the-selection → complete-the-handshake**, process-wide — see the wlroots
|
||||
/// backend's `SELECTION_LOCK`. The xdph selection is likewise one per-user file, so a concurrent
|
||||
/// write between ours and xdph's read would silently steer capture at the other session's output.
|
||||
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
|
||||
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(output)).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-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an **existing** Hyprland monitor — the monitor-mirror path
|
||||
/// (`design/per-monitor-portal-capture.md` L3): the same custom-picker mechanism the virtual-output
|
||||
/// path uses, pointed at a physical connector, so no GUI picker is involved.
|
||||
///
|
||||
/// The keepalive stops the cast only — the monitor is Hyprland's, not ours.
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every head Hyprland reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// `hyprctl -j monitors all` (rather than plain `monitors`) so **disabled** heads are listed too —
|
||||
|
||||
@@ -1030,7 +1030,7 @@ fn virtual_output_thread(
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(u32, Box<dyn Send>)> {
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let pointer_mode = if hw_cursor {
|
||||
POINTER_METADATA
|
||||
} else {
|
||||
@@ -1053,7 +1053,12 @@ pub(crate) fn stream_existing_output(
|
||||
Ok(Err(e)) => bail!("KWin monitor mirror failed: {e}"),
|
||||
Err(_) => bail!("timed out recording the KWin output {connector:?}"),
|
||||
};
|
||||
Ok((node_id, Box::new(StopOnDrop(stop)) as Box<dyn Send>))
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
|
||||
remote_fd: None,
|
||||
keepalive: Box::new(StopOnDrop(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops the mirror thread (and thus the recording) when the capturer drops it.
|
||||
|
||||
@@ -390,7 +390,7 @@ fn session_thread(
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<(u32, Box<dyn Send>)> {
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
@@ -404,7 +404,12 @@ pub(crate) fn stream_existing_output(
|
||||
Ok(Err(e)) => bail!("Mutter monitor mirror failed: {e}"),
|
||||
Err(_) => bail!("timed out recording the Mutter output {connector:?}"),
|
||||
};
|
||||
Ok((node_id, Box::new(MirrorStop(stop)) as Box<dyn Send>))
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
|
||||
remote_fd: None,
|
||||
keepalive: Box::new(MirrorStop(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops the mirror thread — and with it the recording — when the capturer drops it.
|
||||
|
||||
@@ -111,25 +111,13 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
swaymsg(&["output", &name, "enable"])
|
||||
.with_context(|| format!("swaymsg output {name} enable"))?;
|
||||
|
||||
// Steer xdpw's headless output chooser 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_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {name}\n"))
|
||||
.with_context(|| format!("write {chooser}"))?;
|
||||
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();
|
||||
let hw_cursor = self.hw_cursor;
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
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}"),
|
||||
// Steer xdpw's headless output chooser at our new output, then run the portal handshake on
|
||||
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
|
||||
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
|
||||
// read would silently capture the wrong output (see `SELECTION_LOCK`).
|
||||
let (fd, node_id, stop) = {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
select_and_cast(&name, self.hw_cursor)?
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
@@ -238,6 +226,54 @@ fn output_names() -> Result<Vec<String>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Serializes **write-the-chooser → complete-the-handshake**, process-wide.
|
||||
///
|
||||
/// The chooser is a single per-user file: whoever writes last before xdpw reads wins. Two sessions
|
||||
/// starting at once (or a mirror starting beside a virtual output) would otherwise race, and the
|
||||
/// loser doesn't fail — it silently captures the *other* session's output. Held across the portal
|
||||
/// handshake, not just the write, because the read happens inside it.
|
||||
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
|
||||
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {output}\n"))
|
||||
.with_context(|| format!("write {chooser}"))?;
|
||||
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-wlr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an **existing** sway output — the monitor-mirror path
|
||||
/// (`design/per-monitor-portal-capture.md` L3). Same chooser mechanism the virtual-output path
|
||||
/// uses, pointed at a physical connector instead of a headless one we created, so it inherits the
|
||||
/// "no GUI picker" property a background service needs.
|
||||
///
|
||||
/// The keepalive stops the cast and nothing else: sway keeps the monitor, because we never made it.
|
||||
pub(crate) fn stream_existing_output(
|
||||
connector: &str,
|
||||
hw_cursor: bool,
|
||||
) -> Result<crate::mirror::MirrorStream> {
|
||||
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every head sway reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// `swaymsg -t get_outputs` reports `rect` in the logical coordinate space (post-scale,
|
||||
|
||||
@@ -22,6 +22,19 @@ use super::monitors;
|
||||
use crate::{Compositor, Mode};
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// What a backend hands back when it starts recording an existing head.
|
||||
///
|
||||
/// `remote_fd` is the split: KWin and Mutter publish the node on the user's own PipeWire daemon
|
||||
/// (nothing to carry), while the portal-based backends (sway/xdpw, Hyprland/xdph) hand back a
|
||||
/// sandboxed remote fd that the capturer must connect through — the same distinction their
|
||||
/// *virtual*-output paths already make.
|
||||
pub(crate) struct MirrorStream {
|
||||
pub node_id: u32,
|
||||
pub remote_fd: Option<std::os::fd::OwnedFd>,
|
||||
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
|
||||
pub keepalive: Box<dyn Send>,
|
||||
}
|
||||
|
||||
/// Streams an existing monitor, named by connector.
|
||||
pub struct MirrorDisplay {
|
||||
compositor: Compositor,
|
||||
@@ -59,11 +72,11 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
let monitors = monitors::list(self.compositor)
|
||||
.with_context(|| format!("enumerate monitors to mirror {:?}", self.connector))?;
|
||||
let target = monitors::resolve(&monitors, &self.connector)?;
|
||||
check_mirrorable(target)?;
|
||||
check_mirrorable(target, self.compositor)?;
|
||||
let origin = (target.x, target.y);
|
||||
let dims = (target.width, target.height, refresh_hz(target.refresh_mhz));
|
||||
|
||||
let (node_id, keepalive) = match self.compositor {
|
||||
let stream = match self.compositor {
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Kwin => {
|
||||
crate::kwin::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
@@ -72,9 +85,19 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
Compositor::Mutter => {
|
||||
crate::mutter::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Wlroots => {
|
||||
crate::wlroots::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Compositor::Hyprland => {
|
||||
crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||
}
|
||||
// gamescope is nested — it has no physical heads of its own, and `monitors::list`
|
||||
// already returned an empty set, so `resolve` failed before we got here. This arm
|
||||
// exists for exhaustiveness, not as a reachable path.
|
||||
other => bail!(
|
||||
"mirroring an existing monitor is not implemented for the {} backend yet — \
|
||||
unset PUNKTFUNK_CAPTURE_MONITOR",
|
||||
"mirroring an existing monitor is not supported on the {} backend",
|
||||
other.id()
|
||||
),
|
||||
};
|
||||
@@ -87,22 +110,25 @@ impl VirtualDisplay for MirrorDisplay {
|
||||
connector = %target.connector,
|
||||
mode = %target.mode_label(),
|
||||
at = %format!("+{}+{}", origin.0, origin.1),
|
||||
node_id,
|
||||
node_id = stream.node_id,
|
||||
"mirroring an existing monitor (no virtual display created)"
|
||||
);
|
||||
|
||||
// The keepalive IS the recording: dropping it stops the cast and leaves the monitor exactly
|
||||
// as it was (we created nothing, so there is nothing to restore).
|
||||
let mut out = VirtualOutput::owned(node_id, Some(dims), keepalive);
|
||||
let mut out = VirtualOutput::owned(stream.node_id, Some(dims), stream.keepalive);
|
||||
// Portal-based backends (sway/xdpw, Hyprland/xdph) publish on a sandboxed remote the
|
||||
// capturer must connect through; KWin/Mutter use the user's own daemon.
|
||||
out.remote_fd = stream.remote_fd;
|
||||
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
|
||||
out.ownership = DisplayOwnership::External;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this head be mirrored at all? Both rejections are things a compositor would answer with
|
||||
/// silence or a black stream, so they are caught here where the reason can be stated.
|
||||
fn check_mirrorable(target: &monitors::PhysicalMonitor) -> Result<()> {
|
||||
/// Can this head be mirrored at all? Each rejection is something a compositor would otherwise
|
||||
/// answer with silence or a black stream, so it is caught here where the reason can be stated.
|
||||
fn check_mirrorable(target: &monitors::PhysicalMonitor, compositor: Compositor) -> Result<()> {
|
||||
if !target.enabled {
|
||||
bail!(
|
||||
"monitor {:?} is disabled — enable it before streaming it",
|
||||
@@ -110,10 +136,23 @@ fn check_mirrorable(target: &monitors::PhysicalMonitor) -> Result<()> {
|
||||
);
|
||||
}
|
||||
if target.managed {
|
||||
bail!(
|
||||
"monitor {:?} is one of punktfunk's own virtual displays, not a physical head — unset \
|
||||
PUNKTFUNK_CAPTURE_MONITOR to use the normal virtual-display path",
|
||||
target.connector
|
||||
// `managed` is only *conclusive* where the name is ours by construction: KWin's
|
||||
// `Virtual-punktfunk` prefix, Hyprland's `PF-N`. Sway names EVERY headless output
|
||||
// `HEADLESS-N` — its own included — so refusing there would block a legitimate setup
|
||||
// (a headless sway box whose outputs are all HEADLESS-N is exactly the remote case this
|
||||
// feature serves). Warn and continue; a user who really did pin our own virtual display
|
||||
// sees a mirror of it and the log says why.
|
||||
if names_ours_conclusively(compositor) {
|
||||
bail!(
|
||||
"monitor {:?} is one of punktfunk's own virtual displays, not a physical head — \
|
||||
clear the streamed-screen setting to use the normal virtual-display path",
|
||||
target.connector
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
connector = %target.connector,
|
||||
"the pinned monitor looks like a headless output — on this compositor that name is \
|
||||
ambiguous (it may be one of punktfunk's own virtual displays), mirroring it anyway"
|
||||
);
|
||||
}
|
||||
if target.width == 0 || target.height == 0 {
|
||||
@@ -127,6 +166,13 @@ fn check_mirrorable(target: &monitors::PhysicalMonitor) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does this compositor's `managed` flag mean "ours, for certain"? KWin outputs carry the
|
||||
/// `Virtual-punktfunk` prefix we chose, and Hyprland's are `PF-N` — both ours by construction.
|
||||
/// Sway's `HEADLESS-N` is sway's own generic naming, so it is a hint, not proof.
|
||||
fn names_ours_conclusively(compositor: Compositor) -> bool {
|
||||
matches!(compositor, Compositor::Kwin | Compositor::Hyprland)
|
||||
}
|
||||
|
||||
/// mHz → whole Hz for [`VirtualOutput::preferred_mode`], never 0 (the negotiation treats 0 as
|
||||
/// "unset", and a head that didn't report a refresh is still running at *some* rate — 60 is the
|
||||
/// same assumption KWin's own virtual outputs are born with).
|
||||
@@ -161,7 +207,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_real_enabled_head_is_mirrorable() {
|
||||
assert!(check_mirrorable(&head("DP-2")).is_ok());
|
||||
assert!(check_mirrorable(&head("DP-2"), Compositor::Kwin).is_ok());
|
||||
}
|
||||
|
||||
/// Streaming a dark head would be a black rectangle with no explanation — say why instead.
|
||||
@@ -169,7 +215,9 @@ mod tests {
|
||||
fn a_disabled_head_is_refused_with_the_reason() {
|
||||
let mut m = head("DP-2");
|
||||
m.enabled = false;
|
||||
let err = check_mirrorable(&m).unwrap_err().to_string();
|
||||
let err = check_mirrorable(&m, Compositor::Kwin)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("disabled"), "{err}");
|
||||
}
|
||||
|
||||
@@ -179,17 +227,34 @@ mod tests {
|
||||
fn one_of_our_own_virtual_displays_is_refused() {
|
||||
let mut m = head("Virtual-punktfunk-1");
|
||||
m.managed = true;
|
||||
let err = check_mirrorable(&m).unwrap_err().to_string();
|
||||
let err = check_mirrorable(&m, Compositor::Kwin)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("virtual displays"), "{err}");
|
||||
}
|
||||
|
||||
/// Sway names every headless output `HEADLESS-N`, its own included, so the `managed` hint is
|
||||
/// NOT proof there — refusing would block a headless sway box, which is exactly the remote
|
||||
/// setup this feature serves. (Found on-glass: a two-output headless sway had both heads
|
||||
/// flagged, and the KWin-strength rule would have refused to mirror either.)
|
||||
#[test]
|
||||
fn a_headless_sway_output_is_mirrored_despite_the_ambiguous_name() {
|
||||
let mut m = head("HEADLESS-2");
|
||||
m.managed = true;
|
||||
assert!(check_mirrorable(&m, Compositor::Wlroots).is_ok());
|
||||
// Hyprland's `PF-N` IS our naming, so it stays conclusive.
|
||||
assert!(check_mirrorable(&m, Compositor::Hyprland).is_err());
|
||||
}
|
||||
|
||||
/// A head listed but not driving a mode (enabled yet modeless) would negotiate a 0x0 stream.
|
||||
#[test]
|
||||
fn a_head_with_no_current_mode_is_refused() {
|
||||
let mut m = head("DP-2");
|
||||
m.width = 0;
|
||||
m.height = 0;
|
||||
let err = check_mirrorable(&m).unwrap_err().to_string();
|
||||
let err = check_mirrorable(&m, Compositor::Kwin)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("no current mode"), "{err}");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user