topology: exclusive was echoed back by the API and dropped on Hyprland and sway
#304
@@ -143,6 +143,23 @@ pub struct HyprlandDisplay {
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
|
||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
|
||||
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
|
||||
/// physical head is never left dark. Mirrors `kwin.rs`'s field of the same name.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for HyprlandDisplay {
|
||||
fn drop(&mut self) {
|
||||
// Backstop only: the registry takes the restore right after `create` (moving it into the
|
||||
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
|
||||
// than strand the operator's heads dark.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HyprlandDisplay {
|
||||
@@ -150,8 +167,32 @@ impl HyprlandDisplay {
|
||||
Ok(HyprlandDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
pending_restore: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
|
||||
/// the restore for the registry (see [`Self::pending_restore`]).
|
||||
///
|
||||
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
|
||||
/// after it, so there is no path that disables the operator's heads and then unwinds past the
|
||||
/// point where the restore is handed over. The cost is that the physical heads stay lit for the
|
||||
/// duration of the portal handshake, which is the pre-existing `extend` behaviour anyway.
|
||||
fn apply_topology(&mut self, ours: &str) {
|
||||
use crate::policy::Topology;
|
||||
match crate::effective_topology() {
|
||||
// Nothing to do — the headless output joins the desk as one more head, which is what
|
||||
// `create` has already built.
|
||||
Topology::Extend | Topology::Auto => {}
|
||||
Topology::Primary => warn_primary_is_not_expressible(),
|
||||
Topology::Exclusive => {
|
||||
let disabled = disable_other_heads(ours);
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by
|
||||
@@ -220,12 +261,15 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Log the permission-system caveat once per process (silent black frames otherwise).
|
||||
preflight_once();
|
||||
// Remove any output a PREVIOUS host left in this compositor, before we mint our first.
|
||||
reclaim_leftovers_once();
|
||||
warn_topology_is_extend_only();
|
||||
|
||||
let name = next_output_name();
|
||||
hyprctl_dispatch(&["output", "create", "headless", &name]).with_context(|| {
|
||||
@@ -264,6 +308,9 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
cursor = cursor_mode.name(),
|
||||
"hyprland headless output ready"
|
||||
);
|
||||
// Display-management topology (design §5.2). Last, so no failure path unwinds past the
|
||||
// hand-off of the restore — see [`HyprlandDisplay::apply_topology`].
|
||||
self.apply_topology(&name);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
@@ -423,11 +470,20 @@ fn reclaim_leftovers_once() {
|
||||
/// remote mouse motion can focus-follows-mouse its way over, and no window rule names our output.
|
||||
/// So without this, every app the host launches for the session — the whole game library — opens on
|
||||
/// a monitor the client cannot see, and the stream shows a bare desktop. This is the EXTEND-topology
|
||||
/// answer to that: it steers window placement without touching the operator's heads (which is what
|
||||
/// [`warn_topology_is_extend_only`] is still telling the truth about).
|
||||
/// answer to that: it steers window placement without touching the operator's heads — which is also
|
||||
/// the whole of what `topology: primary` can mean here (see [`warn_primary_is_not_expressible`]).
|
||||
/// Under `exclusive` it is called a second time, after the heads are disabled, because that moves
|
||||
/// focus (see [`disable_other_heads`]).
|
||||
///
|
||||
/// Best-effort by construction: a failure costs window placement, not the session, and a box with no
|
||||
/// physical head was already placing windows correctly.
|
||||
///
|
||||
/// ⚠️ **This is a no-op under the Lua config manager.** Measured on .138 (0.55.4, Lua) 2026-08-18:
|
||||
/// `hyprctl dispatch focusmonitor <name>` is parsed as Lua (`hl.dispatch(focusmonitor <name>)`) and
|
||||
/// rejected, and `hl.dsp.focusmonitor` does not exist either — so the #283 window-placement fix
|
||||
/// does not reach a Lua-configured box. Both rejections carry "error", so [`hyprctl_dispatch`]
|
||||
/// reports them and this warns rather than failing silently; the gap itself is unfixed and belongs
|
||||
/// to the #283 follow-up, not to the topology work here.
|
||||
pub(crate) fn focus_output(name: &str) {
|
||||
match hyprctl_dispatch(&focus_argv(name)) {
|
||||
Ok(()) => tracing::info!(output = %name, "focused the streamed headless output"),
|
||||
@@ -450,21 +506,233 @@ fn focus_argv(name: &str) -> [&str; 3] {
|
||||
["dispatch", "focusmonitor", name]
|
||||
}
|
||||
|
||||
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
|
||||
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
|
||||
/// (sweep 13.18). The Hyprland headless output is always an EXTENSION: [`focus_output`] steers new
|
||||
/// windows onto it, but nothing here promotes it to primary or disables the operator's heads.
|
||||
fn warn_topology_is_extend_only() {
|
||||
let topology = crate::effective_topology();
|
||||
if !matches!(
|
||||
topology,
|
||||
crate::policy::Topology::Extend | crate::policy::Topology::Auto
|
||||
) {
|
||||
/// `topology: primary` has no expression on this compositor, and saying so once per create is the
|
||||
/// honest implementation (design §5.2 gives the whole wlr family "**unsupported** (no primary
|
||||
/// concept) → log + treat as extend").
|
||||
///
|
||||
/// Wayland has no primary-output concept at all, and Hyprland's nearest equivalent is the *focused*
|
||||
/// monitor — which [`focus_output`] already points at the streamed head for every session, whatever
|
||||
/// the topology says. So `primary` is not silently dropped so much as already granted, as far as
|
||||
/// this compositor can express it; what an operator does NOT get is a persistent designation other
|
||||
/// clients can read. Distinct from the `exclusive` path, which really does change the desk.
|
||||
fn warn_primary_is_not_expressible() {
|
||||
tracing::info!(
|
||||
"hyprland: `topology: primary` has no equivalent here — Wayland has no primary output and \
|
||||
Hyprland has only a FOCUSED monitor, which the streamed head already holds. Treating it \
|
||||
as `extend`; use `exclusive` to actually disable the operator's heads."
|
||||
);
|
||||
}
|
||||
|
||||
/// Which heads an `exclusive` topology should disable: enabled, not ours, and **not managed**.
|
||||
///
|
||||
/// Pure so the group-awareness rule (design §6.1 — "exclusive means the *managed virtual displays*
|
||||
/// are the only enabled outputs; never disable a sibling slot") is unit-testable without a
|
||||
/// compositor. `managed` comes from [`list_monitors`], i.e. [`is_managed_output`]: `PF-<pid>-<n>`,
|
||||
/// which covers a SECOND host's outputs as well as our own, so a concurrent session's screen can
|
||||
/// never be blacked out by ours. `ours` is excluded by name too — belt and braces, since our own
|
||||
/// output is managed by construction and the one head that must survive.
|
||||
fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> Vec<String> {
|
||||
heads
|
||||
.iter()
|
||||
.filter(|h| h.enabled && !h.managed && h.connector != ours)
|
||||
.map(|h| h.connector.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
|
||||
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
|
||||
/// that screen, not the session.
|
||||
fn disable_other_heads(ours: &str) -> Vec<String> {
|
||||
let heads = match list_monitors() {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"hyprland: could not enumerate monitors for `topology: exclusive` — leaving the \
|
||||
operator's heads enabled (the session still streams, as `extend`)"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let targets = heads_to_disable(&heads, ours);
|
||||
if targets.is_empty() {
|
||||
tracing::info!(
|
||||
"hyprland: `topology: exclusive` had nothing to disable — no enabled head besides the \
|
||||
managed ones (a headless box, or a sibling session already took the desk)"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
let mut disabled = Vec::new();
|
||||
for name in targets {
|
||||
match disable_head(&name) {
|
||||
Ok(()) => disabled.push(name),
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"hyprland: could not disable this head for `topology: exclusive` — it stays lit"
|
||||
),
|
||||
}
|
||||
}
|
||||
if !disabled.is_empty() {
|
||||
tracing::info!(
|
||||
?disabled,
|
||||
"hyprland: `topology: exclusive` — the streamed output is now the desk"
|
||||
);
|
||||
// Disabling heads re-homes their workspaces, and the compositor picks the replacement
|
||||
// focus itself. Re-assert ours so window placement still lands on the stream (the #283
|
||||
// contract) rather than on whichever head Hyprland happened to choose.
|
||||
focus_output(ours);
|
||||
}
|
||||
disabled
|
||||
}
|
||||
|
||||
/// Disable one head, supporting **both config eras** and confirming by read-back.
|
||||
///
|
||||
/// Same two-era shape as [`set_monitor_rule`], and for the same reason: `hyprctl keyword` is the
|
||||
/// hyprlang form and is *rejected outright* under the Lua config manager ("keyword can't work with
|
||||
/// non-legacy parsers. Use eval."), while `hyprctl eval` is rejected under hyprlang ("eval is only
|
||||
/// supported with the lua config manager"). Both rejections come back at **exit 0**, so the read-back
|
||||
/// — not the exit status, and not the `ok` — is what decides. Measured 2026-08-18 on Hyprland
|
||||
/// 0.56.2 (hyprlang, `.21`) and 0.55.4 (Lua, `.138`); both spellings disable, both verified by
|
||||
/// `disabled: true` in `hyprctl -j monitors all`.
|
||||
fn disable_head(name: &str) -> Result<()> {
|
||||
let spec = disable_rule_spec(name);
|
||||
let lua = disable_lua_expr(name);
|
||||
let keyword: Vec<&str> = vec!["keyword", "monitor", &spec];
|
||||
let eval: Vec<&str> = vec!["eval", &lua];
|
||||
let mut attempts: Vec<String> = Vec::new();
|
||||
for a in [&keyword, &eval] {
|
||||
if let Err(e) = hyprctl_dispatch(a) {
|
||||
let said = format!("{e:#}");
|
||||
tracing::debug!(output = %name, cmd = ?a, error = %said, "hyprctl rejected this disable form — trying the other config era");
|
||||
attempts.push(said);
|
||||
continue;
|
||||
}
|
||||
if wait_head_disabled(name, DISABLE_BUDGET) {
|
||||
return Ok(());
|
||||
}
|
||||
attempts.push(format!(
|
||||
"hyprctl {a:?} was accepted but the head never went disabled"
|
||||
));
|
||||
}
|
||||
bail!("no hyprctl form disabled {name}: {}", attempts.join("; "))
|
||||
}
|
||||
|
||||
/// The **hyprlang** disable rule for `name` (`hyprctl keyword monitor <this>`), split out so a test
|
||||
/// pins its shape. `disable` is a whole-rule verb and replaces the resolution field — there is no
|
||||
/// `<name>,<mode>,disable`, and (measured) no `<name>,enable` to undo it.
|
||||
fn disable_rule_spec(name: &str) -> String {
|
||||
format!("{name},disable")
|
||||
}
|
||||
|
||||
/// The **Lua** disable rule for `name` (`hyprctl eval <this>`), split out so a test pins its shape.
|
||||
///
|
||||
/// The field is `disabled` (past tense) and takes a boolean. Measured on .138: `disable = true` is
|
||||
/// rejected with "unknown field 'disable'", and `mode = "disable"` with "error applying field
|
||||
/// 'mode'" — the hyprlang spelling does not carry over, so this is not a place to guess.
|
||||
fn disable_lua_expr(name: &str) -> String {
|
||||
format!("hl.monitor{{ output = \"{name}\", disabled = true }}")
|
||||
}
|
||||
|
||||
/// Poll until `name` reports `disabled: true` (the rule applies asynchronously), up to `timeout`.
|
||||
fn wait_head_disabled(name: &str, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if matches!(head_is_enabled(name), Ok(Some(false))) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is head `name` currently enabled? `None` if it is not present at all. Reads `-j monitors all`,
|
||||
/// which is the only listing that includes a DISABLED head (the plain `-j monitors` drops it, so
|
||||
/// asking that one "is it disabled?" cannot distinguish disabled from unplugged).
|
||||
fn head_is_enabled(name: &str) -> Result<Option<bool>> {
|
||||
let out = hyprctl(&["-j", "monitors", "all"])?;
|
||||
let monitors: serde_json::Value =
|
||||
serde_json::from_str(&out).context("parse hyprctl -j monitors all")?;
|
||||
let Some(arr) = monitors.as_array() else {
|
||||
return Ok(None);
|
||||
};
|
||||
for m in arr {
|
||||
if m.get("name").and_then(|n| n.as_str()) == Some(name) {
|
||||
return Ok(Some(
|
||||
!m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// How long a `disable` (or the `reload` that undoes it) has to show up in `hyprctl -j monitors
|
||||
/// all`. Generous next to the measured near-instant apply; a miss is reported, never assumed.
|
||||
const DISABLE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Re-enable the heads an `exclusive` session disabled. Run by the REGISTRY when the display
|
||||
/// group's last member is torn down (design §6.1) and, critically, **before** that member's output
|
||||
/// is removed — so Hyprland never sees zero enabled outputs.
|
||||
///
|
||||
/// 🛑🛑 **`hyprctl reload` is the only thing that re-enables a disabled head, and that is measured,
|
||||
/// not chosen.** The obvious restore — re-apply the head's own mode/position/scale, which is what
|
||||
/// `design/display-management.md` §5.2 assumes and what the issue (#284) proposed — **does not
|
||||
/// work**: it answers `ok` and leaves `disabled: true`. Probed 2026-08-18 against Hyprland 0.56.2
|
||||
/// (hyprlang) and 0.55.4 (Lua); every one of these was accepted and changed nothing:
|
||||
///
|
||||
/// * `keyword monitor <name>,<W>x<H>@<Hz>,<x>x<y>,<scale>` (the exact pre-disable rule)
|
||||
/// * `keyword monitor <name>,preferred,auto,1`
|
||||
/// * `keyword monitor <name>,enable` — not a verb: answers `invalid resolution`
|
||||
/// * `keyword monitorv2 output=<name>,…,disabled=false`
|
||||
/// * `keyword unset monitor`
|
||||
/// * `eval 'hl.monitor{ output = "<name>", disabled = false, … }'` (the Lua twin)
|
||||
/// * `dispatch dpms on <name>` — DPMS is a different axis; the head stays disabled
|
||||
/// * `dispatch forcerendererreload`
|
||||
///
|
||||
/// A runtime `monitor` rule is additive, and the `disable` in it keeps winning; only re-reading the
|
||||
/// config clears the runtime rules. So the restore is the operator's own config, re-applied — which
|
||||
/// for a config-driven compositor is exactly what "put it back how it was" means.
|
||||
///
|
||||
/// ⚠️ The side effects are real and worth knowing: a reload drops **every** runtime `hyprctl
|
||||
/// keyword`/`eval` override, including our own monitor rule for the streamed output (harmless — the
|
||||
/// output is removed moments later by the same teardown) and any the operator set by hand; and on a
|
||||
/// hyprlang config it re-runs `exec =` lines (`exec-once` is not re-run, and a Lua config's
|
||||
/// `hl.on("hyprland.start", …)` autostart does not re-fire either). This runs ONLY when we actually
|
||||
/// disabled something, so a box that never used `exclusive` never pays it.
|
||||
fn restore_heads(disabled: &[String]) {
|
||||
if let Err(e) = hyprctl_dispatch(&["reload"]) {
|
||||
tracing::error!(
|
||||
?disabled, error = %format!("{e:#}"),
|
||||
"hyprland: `hyprctl reload` failed — the heads this session disabled are still dark. \
|
||||
Re-run `hyprctl reload` by hand to get them back."
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Report the OUTCOME, not the request: `reload` answers `ok` for "config parsed", which is not
|
||||
// the same as "the head came back" (a head the operator's own config disables stays disabled,
|
||||
// correctly). Read it back so a field report says which screens actually returned.
|
||||
let deadline = Instant::now() + DISABLE_BUDGET;
|
||||
let still_dark = loop {
|
||||
let dark: Vec<&String> = disabled
|
||||
.iter()
|
||||
.filter(|n| matches!(head_is_enabled(n), Ok(Some(false))))
|
||||
.collect();
|
||||
if dark.is_empty() || Instant::now() >= deadline {
|
||||
break dark;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
if still_dark.is_empty() {
|
||||
tracing::info!(
|
||||
?disabled,
|
||||
"hyprland: re-enabled the heads `topology: exclusive` disabled"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
?topology,
|
||||
"hyprland: this backend implements EXTEND only — the headless output is added beside \
|
||||
the operator's heads and nothing is promoted or disabled. Configure `topology: extend` \
|
||||
to stop the console promising otherwise."
|
||||
?disabled, ?still_dark,
|
||||
"hyprland: `hyprctl reload` ran but these heads are still disabled — the operator's own \
|
||||
config may disable them, otherwise they need a manual `hyprctl reload`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -686,6 +954,13 @@ fn hyprctl_dispatch(args: &[&str]) -> Result<()> {
|
||||
// config manager" — a rejection hyprctl reports with exit 0 and no other marker.
|
||||
|| lc.contains("only supported")
|
||||
|| lc.contains("not supported")
|
||||
// The MIRROR rejection, and it matched none of the markers above: `hyprctl keyword` on a
|
||||
// Lua config answers "keyword can't work with non-legacy parsers. Use eval." — note
|
||||
// "can't", not the "couldn't" that was already covered. Measured on .138 (0.55.4, Lua).
|
||||
// Without this the wrong-era `keyword` read as SUCCESS, and every caller then had to
|
||||
// notice the miss for itself by reading the state back.
|
||||
|| lc.contains("can't")
|
||||
|| lc.contains("cannot")
|
||||
{
|
||||
bail!("hyprctl {:?} rejected: {t}", args);
|
||||
}
|
||||
@@ -1172,4 +1447,85 @@ mod tests {
|
||||
fn picker_line_is_the_shared_selection_format() {
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
|
||||
}
|
||||
|
||||
fn head(connector: &str, enabled: bool) -> crate::monitors::PhysicalMonitor {
|
||||
crate::monitors::PhysicalMonitor {
|
||||
connector: connector.to_string(),
|
||||
description: connector.to_string(),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_mhz: 60_000,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
primary: false,
|
||||
enabled,
|
||||
// The real `list_monitors` derives this with `is_managed_output`; mirror it here so the
|
||||
// fixture can't drift into asserting a rule the backend doesn't actually apply.
|
||||
managed: is_managed_output(connector),
|
||||
}
|
||||
}
|
||||
|
||||
/// The group-awareness rule (design §6.1): `exclusive` disables the operator's heads and
|
||||
/// **only** those. A sibling session's output — ours or another host's, both `PF-<pid>-<n>` —
|
||||
/// must survive, or the second exclusive session blacks out the first one's screen, which is
|
||||
/// the exact bug KWin's Stage 3 shipped and Stage 5 fixed.
|
||||
#[test]
|
||||
fn exclusive_disables_the_operators_heads_and_never_a_managed_sibling() {
|
||||
let ours = "PF-4242-1";
|
||||
let heads = [
|
||||
head("DP-1", true),
|
||||
head("HDMI-A-1", true),
|
||||
head(ours, true),
|
||||
// A concurrent session's output, and one from a second host — both managed.
|
||||
head("PF-4242-2", true),
|
||||
head("PF-99-1", true),
|
||||
// Already off: nothing to disable, and it must NOT end up in the restore list, or
|
||||
// teardown would switch on a head the operator had deliberately left dark.
|
||||
head("DP-3", false),
|
||||
];
|
||||
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
|
||||
}
|
||||
|
||||
/// A box with no physical head (the CI/headless posture) has nothing to disable, so no restore
|
||||
/// is prepared and teardown never runs a `hyprctl reload` — the reload's side effects are paid
|
||||
/// only by a session that actually took a screen.
|
||||
#[test]
|
||||
fn exclusive_on_a_headless_box_disables_nothing() {
|
||||
let ours = "PF-4242-1";
|
||||
assert!(heads_to_disable(&[head(ours, true)], ours).is_empty());
|
||||
}
|
||||
|
||||
/// Both config eras, pinned. These two strings are the whole contract with the compositor and
|
||||
/// neither is guessable: `hyprctl` answers a wrong-era or malformed rule at **exit 0**, so a
|
||||
/// typo here reads as success and the operator's screen simply stays lit under `exclusive`.
|
||||
#[test]
|
||||
fn disable_rules_are_pinned_for_both_config_eras() {
|
||||
assert_eq!(disable_rule_spec("DP-1"), "DP-1,disable");
|
||||
assert_eq!(
|
||||
disable_lua_expr("DP-1"),
|
||||
r#"hl.monitor{ output = "DP-1", disabled = true }"#
|
||||
);
|
||||
}
|
||||
|
||||
/// `hyprctl keyword` under the Lua config manager answers "keyword can't work with non-legacy
|
||||
/// parsers. Use eval." at exit 0 — the one rejection shape the marker list used to miss, so the
|
||||
/// wrong-era form reported success. (Its mirror, `eval` under hyprlang, was already covered.)
|
||||
#[test]
|
||||
fn a_wrong_era_rejection_is_an_error_not_a_success() {
|
||||
for said in [
|
||||
"keyword can't work with non-legacy parsers. Use eval.",
|
||||
"eval is only supported with the lua config manager",
|
||||
"invalid resolution ",
|
||||
] {
|
||||
let lc = said.to_ascii_lowercase();
|
||||
assert!(
|
||||
lc.contains("can't")
|
||||
|| lc.contains("cannot")
|
||||
|| lc.contains("only supported")
|
||||
|| lc.contains("invalid"),
|
||||
"{said:?} must match a marker in hyprctl_dispatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,23 @@ pub struct WlrootsDisplay {
|
||||
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
|
||||
/// overlay is never coming instead of inferring it from an absence.
|
||||
last_cursor_mode: Option<crate::portal_cursor::Mode>,
|
||||
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
|
||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
|
||||
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
|
||||
/// physical head is never left dark. Mirrors `kwin.rs` and the Hyprland twin.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for WlrootsDisplay {
|
||||
fn drop(&mut self) {
|
||||
// Backstop only: the registry takes the restore right after `create` (moving it into the
|
||||
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
|
||||
// than strand the operator's heads dark.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WlrootsDisplay {
|
||||
@@ -80,8 +97,32 @@ impl WlrootsDisplay {
|
||||
Ok(WlrootsDisplay {
|
||||
hw_cursor: false,
|
||||
last_cursor_mode: None,
|
||||
pending_restore: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
|
||||
/// the restore for the registry (see [`Self::pending_restore`]).
|
||||
///
|
||||
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
|
||||
/// after it, so there is no path that disables the operator's heads and then unwinds past the
|
||||
/// point where the restore is handed over. The cost is that the physical heads stay lit for the
|
||||
/// duration of the portal handshake, which is the pre-existing `extend` behaviour anyway.
|
||||
fn apply_topology(&mut self, ours: &str) {
|
||||
use crate::policy::Topology;
|
||||
match crate::effective_topology() {
|
||||
// Nothing to do — the headless output joins the desk as one more head, which is what
|
||||
// `create` has already built.
|
||||
Topology::Extend | Topology::Auto => {}
|
||||
Topology::Primary => warn_primary_is_not_expressible(),
|
||||
Topology::Exclusive => {
|
||||
let disabled = disable_other_heads(ours);
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK`
|
||||
@@ -113,8 +154,11 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
self.last_cursor_mode
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
warn_topology_is_extend_only();
|
||||
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
|
||||
// itself (`HEADLESS-N`), so the only way to know which one is ours is "the name that was not
|
||||
// there before" — and two concurrent creates each picking the other's output is a silent
|
||||
@@ -180,6 +224,9 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
cursor = cursor_mode.name(),
|
||||
"sway headless output ready"
|
||||
);
|
||||
// Display-management topology (design §5.2). Last, so no failure path unwinds past the
|
||||
// hand-off of the restore — see [`WlrootsDisplay::apply_topology`].
|
||||
self.apply_topology(&name);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
@@ -342,22 +389,182 @@ fn focus_argv(name: &str) -> [&str; 3] {
|
||||
["focus", "output", name]
|
||||
}
|
||||
|
||||
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
|
||||
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
|
||||
/// (sweep 13.18). sway's virtual output is always an EXTENSION: [`focus_output`] steers new windows
|
||||
/// onto it, but nothing here promotes it to primary or disables the operator's heads.
|
||||
fn warn_topology_is_extend_only() {
|
||||
let topology = crate::effective_topology();
|
||||
if !matches!(
|
||||
topology,
|
||||
crate::policy::Topology::Extend | crate::policy::Topology::Auto
|
||||
) {
|
||||
tracing::warn!(
|
||||
?topology,
|
||||
"wlroots: this backend implements EXTEND only — the headless output is added beside the \
|
||||
operator's heads and nothing is promoted or disabled. Configure `topology: extend` to \
|
||||
stop the console promising otherwise."
|
||||
/// `topology: primary` has no expression on this compositor, and saying so once per create is the
|
||||
/// honest implementation — design §5.2 spells this row out: "**unsupported** (no primary concept)
|
||||
/// → log + treat as extend".
|
||||
///
|
||||
/// Wayland has no primary-output concept, and sway's nearest equivalent is the *focused* output —
|
||||
/// which [`focus_output`] already points at the streamed head for every session, whatever the
|
||||
/// topology says. So `primary` is not silently dropped so much as already granted, as far as this
|
||||
/// compositor can express it; what an operator does NOT get is a persistent designation other
|
||||
/// clients can read. Distinct from the `exclusive` path, which really does change the desk.
|
||||
fn warn_primary_is_not_expressible() {
|
||||
tracing::info!(
|
||||
"wlroots: `topology: primary` has no equivalent here — Wayland has no primary output and \
|
||||
sway has only a FOCUSED output, which the streamed head already holds. Treating it as \
|
||||
`extend`; use `exclusive` to actually disable the operator's heads."
|
||||
);
|
||||
}
|
||||
|
||||
/// Which heads an `exclusive` topology should disable: enabled, not ours, and **not managed**.
|
||||
///
|
||||
/// Pure so the group-awareness rule (design §6.1 — "exclusive means the *managed virtual displays*
|
||||
/// are the only enabled outputs; never disable a sibling slot") is unit-testable without a
|
||||
/// compositor. `managed` comes from [`list_monitors`], i.e. the `HEADLESS-` prefix, so a concurrent
|
||||
/// session's output is never blacked out by ours.
|
||||
///
|
||||
/// ⚠️ That prefix is [deliberately blunt](is_managed_output): sway names its OWN headless outputs
|
||||
/// the same way we do, so a sway started on the headless backend has a `HEADLESS-1` of its own that
|
||||
/// this filter also spares. The failure that buys is the harmless one — a bootstrap head stays lit
|
||||
/// on a box that has no physical screen anyway — whereas the alternative is disabling a live
|
||||
/// sibling's output. `ours` is excluded by name too, belt and braces.
|
||||
fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> Vec<String> {
|
||||
heads
|
||||
.iter()
|
||||
.filter(|h| h.enabled && !h.managed && h.connector != ours)
|
||||
.map(|h| h.connector.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
|
||||
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
|
||||
/// that screen, not the session.
|
||||
fn disable_other_heads(ours: &str) -> Vec<String> {
|
||||
let heads = match list_monitors() {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"wlroots: could not enumerate outputs for `topology: exclusive` — leaving the \
|
||||
operator's heads enabled (the session still streams, as `extend`)"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let targets = heads_to_disable(&heads, ours);
|
||||
if targets.is_empty() {
|
||||
tracing::info!(
|
||||
"wlroots: `topology: exclusive` had nothing to disable — no enabled output besides the \
|
||||
headless ones (a headless box, or a sibling session already took the desk)"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
let mut disabled = Vec::new();
|
||||
for name in targets {
|
||||
match disable_head(&name) {
|
||||
Ok(()) => disabled.push(name),
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"wlroots: could not disable this output for `topology: exclusive` — it stays lit"
|
||||
),
|
||||
}
|
||||
}
|
||||
if !disabled.is_empty() {
|
||||
tracing::info!(
|
||||
?disabled,
|
||||
"wlroots: `topology: exclusive` — the streamed output is now the desk"
|
||||
);
|
||||
// Disabling outputs moves their workspaces, and sway picks the replacement focus itself.
|
||||
// Re-assert ours so window placement still lands on the stream (the #283 contract).
|
||||
focus_output(ours);
|
||||
}
|
||||
disabled
|
||||
}
|
||||
|
||||
/// Disable one head: `swaymsg output <name> disable`, confirmed by read-back.
|
||||
///
|
||||
/// The read-back is not ceremony. `swaymsg` does report a rejected command with a non-zero exit
|
||||
/// (unlike `hyprctl`, which answers at exit 0 — see the Hyprland twin), so a bad *command* is
|
||||
/// caught by [`swaymsg`] itself; what the read-back adds is proof the output actually went
|
||||
/// inactive, which is the state teardown will have to undo.
|
||||
fn disable_head(name: &str) -> Result<()> {
|
||||
swaymsg(&disable_argv(name)).with_context(|| format!("swaymsg output {name} disable"))?;
|
||||
if wait_head_enabled_is(name, false, DISABLE_BUDGET) {
|
||||
return Ok(());
|
||||
}
|
||||
bail!("swaymsg accepted `output {name} disable` but the output never went inactive")
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that disables `name`, split out so a test pins its SHAPE — the noun comes
|
||||
/// FIRST here (`output <name> disable`), the opposite of [`focus_argv`]'s `focus output <name>`.
|
||||
fn disable_argv(name: &str) -> [&str; 3] {
|
||||
["output", name, "disable"]
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that re-enables `name`. sway keeps a disabled output's configuration, so a
|
||||
/// bare `enable` restores the mode/position/scale it had — there is no need to replay the rule the
|
||||
/// way the Hyprland twin's `reload` does.
|
||||
fn enable_argv(name: &str) -> [&str; 3] {
|
||||
["output", name, "enable"]
|
||||
}
|
||||
|
||||
/// How long a `disable`/`enable` has to show up in `swaymsg -t get_outputs`. Generous next to a
|
||||
/// healthy IPC round trip; a miss is reported, never assumed.
|
||||
const DISABLE_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Poll until `name`'s enabled state equals `want`, up to `timeout`. `false` on timeout.
|
||||
fn wait_head_enabled_is(name: &str, want: bool, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if matches!(head_is_enabled(name), Ok(Some(got)) if got == want) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is output `name` currently enabled (sway's `active`)? `None` if it is not present at all.
|
||||
/// A disabled output is still listed by `get_outputs`, with `"active": false` — which is what makes
|
||||
/// this a usable read-back rather than a presence check.
|
||||
fn head_is_enabled(name: &str) -> Result<Option<bool>> {
|
||||
let parsed = swaymsg_query("get_outputs")?;
|
||||
let Some(arr) = parsed.as_array() else {
|
||||
return Ok(None);
|
||||
};
|
||||
for o in arr {
|
||||
if o.get("name").and_then(|n| n.as_str()) == Some(name) {
|
||||
return Ok(Some(
|
||||
o.get("active").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Re-enable the outputs an `exclusive` session disabled. Run by the REGISTRY when the display
|
||||
/// group's last member is torn down (design §6.1) and, critically, **before** that member's output
|
||||
/// is unplugged — so sway never sees zero enabled outputs.
|
||||
///
|
||||
/// ⚠️ **Not exercised on a live sway.** No box in the fleet runs one (the 2026-08-18 probes had
|
||||
/// Hyprland only), which is the same gap PR #283's `focus output` half shipped with and which
|
||||
/// `design/display-management.md` records as "wlroots `exclusive` (needs a Sway box)". The argv is
|
||||
/// sway's documented command surface and is pinned by [`enable_argv`]'s test; the read-back below
|
||||
/// turns a wrong guess into a logged warning naming the outputs, rather than a screen that silently
|
||||
/// stays dark. Unlike Hyprland — where re-applying a rule provably does NOT undo a disable and only
|
||||
/// `hyprctl reload` does — sway's `enable` is the documented inverse of `disable`.
|
||||
fn restore_heads(disabled: &[String]) {
|
||||
for name in disabled {
|
||||
match swaymsg(&enable_argv(name)) {
|
||||
Ok(_) => {
|
||||
if wait_head_enabled_is(name, true, DISABLE_BUDGET) {
|
||||
tracing::info!(output = %name, "wlroots: re-enabled the output `topology: exclusive` disabled");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
output = %name,
|
||||
"wlroots: `output enable` was accepted but the output is still inactive — \
|
||||
re-enable it by hand with `swaymsg output {name} enable`"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"wlroots: could not re-enable this output — it is still dark. Run \
|
||||
`swaymsg output {name} enable` by hand."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,4 +1012,59 @@ mod tests {
|
||||
fn focus_names_the_output_after_the_verb() {
|
||||
assert_eq!(focus_argv("HEADLESS-2"), ["focus", "output", "HEADLESS-2"]);
|
||||
}
|
||||
|
||||
/// The topology pair takes the OTHER shape — `output <name> <verb>`, noun first, like `mode` /
|
||||
/// `unplug` and unlike [`focus_argv`]. Both are pinned because this file legitimately uses both
|
||||
/// orders, which is exactly the condition under which one gets written the wrong way round.
|
||||
#[test]
|
||||
fn disable_and_enable_name_the_output_before_the_verb() {
|
||||
assert_eq!(disable_argv("DP-1"), ["output", "DP-1", "disable"]);
|
||||
assert_eq!(enable_argv("DP-1"), ["output", "DP-1", "enable"]);
|
||||
}
|
||||
|
||||
fn head(connector: &str, enabled: bool) -> crate::monitors::PhysicalMonitor {
|
||||
crate::monitors::PhysicalMonitor {
|
||||
connector: connector.to_string(),
|
||||
description: connector.to_string(),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_mhz: 60_000,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
primary: false,
|
||||
enabled,
|
||||
// The real `list_monitors` derives this from the `HEADLESS-` prefix; mirror it here so
|
||||
// the fixture can't drift into asserting a rule the backend doesn't actually apply.
|
||||
managed: connector.starts_with("HEADLESS-"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The group-awareness rule (design §6.1): `exclusive` disables the operator's outputs and
|
||||
/// **only** those. A sibling session's `HEADLESS-N` must survive, or the second exclusive
|
||||
/// session blacks out the first one's screen — the exact bug KWin's Stage 3 shipped.
|
||||
#[test]
|
||||
fn exclusive_disables_the_operators_outputs_and_never_a_headless_sibling() {
|
||||
let ours = "HEADLESS-2";
|
||||
let heads = [
|
||||
head("DP-1", true),
|
||||
head("HDMI-A-1", true),
|
||||
head(ours, true),
|
||||
// A concurrent session's output — and, indistinguishably, a headless sway's own
|
||||
// bootstrap output. Both are spared; see `heads_to_disable`.
|
||||
head("HEADLESS-1", true),
|
||||
// Already off: nothing to disable, and it must NOT end up in the restore list, or
|
||||
// teardown would switch on an output the operator had deliberately left dark.
|
||||
head("DP-3", false),
|
||||
];
|
||||
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
|
||||
}
|
||||
|
||||
/// A box with no physical output (the CI/headless posture) has nothing to disable, so no
|
||||
/// restore is prepared and teardown touches nothing.
|
||||
#[test]
|
||||
fn exclusive_on_a_headless_box_disables_nothing() {
|
||||
let ours = "HEADLESS-1";
|
||||
assert!(heads_to_disable(&[head(ours, true)], ours).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,18 @@ See [Configuration](/docs/configuration) for the full reference.
|
||||
portal. To pick the output without a GUI on a headless host, the host writes a managed
|
||||
`~/.config/hypr/xdph.conf` pointing xdph's `custom_picker_binary` at a small shim that selects the
|
||||
new output automatically — no interactive picker dialog to answer.
|
||||
- **Window placement** — the headless output is an *extension*: it sits beside your real monitors and
|
||||
nothing promotes it or turns them off. Hyprland opens a new window on the **focused** monitor, so
|
||||
- **Window placement** — under the default *extend* topology the headless output sits beside your
|
||||
real monitors and nothing promotes it. Hyprland opens a new window on the **focused** monitor, so
|
||||
the host runs `hyprctl dispatch focusmonitor PF-…` — once when the output is ready, and again right
|
||||
before it launches anything from your library. Without that, games open on whichever physical
|
||||
monitor had focus and the stream shows a bare desktop.
|
||||
- **Exclusive topology** — if you set it, the host disables your physical monitors for the session
|
||||
(`monitor <name>,disable`, or the Lua `hl.monitor{ …, disabled = true }` if you use a Lua config)
|
||||
and brings them back with a **`hyprctl reload`** at teardown. The reload is not a shortcut: a
|
||||
disabled Hyprland monitor cannot be re-enabled by re-applying its rule — every targeted form is
|
||||
accepted and does nothing — so re-reading your config is the only way back. It also drops other
|
||||
runtime `hyprctl keyword` changes and re-runs `exec =` lines in a non-Lua config, and it runs only
|
||||
when a session actually disabled a monitor.
|
||||
- **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual
|
||||
keyboard** protocols (Hyprland kept them). Gamepads and audio are compositor-independent.
|
||||
|
||||
|
||||
@@ -54,11 +54,15 @@ See [Configuration](/docs/configuration) for the full reference.
|
||||
- **Capture** — it captures that output through the **xdg-desktop-portal-wlr (xdpw)** ScreenCast
|
||||
portal. The host writes a managed chooser config so the output pick is automatic — no interactive
|
||||
picker dialog to answer.
|
||||
- **Window placement** — the headless output is an *extension*: it sits beside your real monitors and
|
||||
nothing promotes it or turns them off. sway opens a new window on the focused workspace, so the host
|
||||
- **Window placement** — under the default *extend* topology the headless output sits beside your
|
||||
real monitors and nothing promotes it. sway opens a new window on the focused workspace, so the host
|
||||
runs `swaymsg focus output HEADLESS-…` — once when the output is ready, and again right before it
|
||||
launches anything from your library. Without that, games open on whichever physical monitor had
|
||||
focus and the stream shows a bare desktop.
|
||||
- **Exclusive topology** — if you set it, the host runs `swaymsg output <name> disable` for each of
|
||||
your physical outputs at session start and `swaymsg output <name> enable` when the last streaming
|
||||
display is torn down. Outputs named `HEADLESS-*` are never disabled, so a second streaming client
|
||||
(and a headless sway's own bootstrap output) is left alone.
|
||||
- **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual
|
||||
keyboard** protocols.
|
||||
|
||||
|
||||
@@ -239,8 +239,10 @@ If it still happens on a host that has the fix:
|
||||
this is your normal way to play, set **Virtual displays → Dedicated game sessions** to **Dedicated**
|
||||
— every launch then gets its own headless gamescope with only the game inside, and placement stops
|
||||
being a question of focus at all (needs `gamescope` installed).
|
||||
- **Setting the topology to Primary or Exclusive won't do it.** Neither is implemented on these two
|
||||
backends — the console accepts the setting and the host logs that it dropped it. See
|
||||
- **Setting the topology to Primary won't do it.** Wayland has no primary output for these two
|
||||
backends to set, so Primary behaves as Extend and the host says so in the log. **Exclusive** *is*
|
||||
implemented here — it switches your physical monitors off for the session and back on afterwards,
|
||||
which does put every window on the stream. See
|
||||
[Virtual displays → Topology](/docs/virtual-displays#topology).
|
||||
|
||||
## The screen stays black after switching to Game Mode (Nobara)
|
||||
|
||||
@@ -29,8 +29,8 @@ different setting and it turns most of this page off — see
|
||||
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
|
||||
> *and* GNOME/Mutter), and **multi-monitor layout** (several clients as monitors of one desktop) are
|
||||
> all enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a
|
||||
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
|
||||
> `exclusive`, and multi-display for a *single* client (that last is the next stage).
|
||||
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, and
|
||||
> multi-display for a *single* client (that last is the next stage).
|
||||
|
||||
## Stream a real monitor instead
|
||||
|
||||
@@ -212,20 +212,30 @@ Per-backend support:
|
||||
|---|---|---|---|---|
|
||||
| Extend | ✅ | ✅ | ✅ | ✅ |
|
||||
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
|
||||
| Exclusive | ✅ | ✅ | ⏳ following release | ✅ |
|
||||
| Exclusive | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
On **Sway/wlroots and Hyprland** the virtual display is always an *extend* output — it is added
|
||||
beside your physical monitors and neither promoted nor allowed to disable them, whatever the topology
|
||||
says. So that "treated as Extend" doesn't leave your games on the wrong screen, the host **claims the
|
||||
compositor's focus for the streamed display**: once at session start, and again immediately before it
|
||||
launches anything from your library. Both compositors open a new window on the focused monitor, so
|
||||
that is what puts the game on the display you're streaming.
|
||||
**Primary** has no equivalent on **Sway/wlroots and Hyprland**, and that is a Wayland fact rather
|
||||
than a missing feature: there is no primary-output concept to set. What these compositors do have is
|
||||
a *focused* output, and the host already points that at the streamed display — once at session start,
|
||||
and again immediately before it launches anything from your library. Both open a new window on the
|
||||
focused monitor, so that is what puts the game on the display you're streaming. Choosing Primary
|
||||
therefore behaves as Extend, and the host says so in the log.
|
||||
|
||||
Two things follow from it being focus rather than promotion. Your physical monitors stay lit and
|
||||
usable — this is Extend, not Exclusive. And a window that opens *later* (a launcher that spawns a
|
||||
second window, a game that re-parents itself) follows whatever has focus at that moment, so if you're
|
||||
also sitting at the machine, clicking on a physical monitor mid-launch can still pull a window over
|
||||
to it.
|
||||
One thing follows from it being focus rather than promotion: a window that opens *later* (a launcher
|
||||
that spawns a second window, a game that re-parents itself) follows whatever has focus at that
|
||||
moment, so if you're also sitting at the machine, clicking on a physical monitor mid-launch can still
|
||||
pull a window over to it.
|
||||
|
||||
**Exclusive** does disable your physical monitors on both, and switches them back on when the last
|
||||
streaming display is torn down. Two details are specific to these compositors:
|
||||
|
||||
- Punktfunk only ever disables monitors it did not create, so a second client streaming at the same
|
||||
time never goes dark.
|
||||
- On Hyprland the restore is a `hyprctl reload`, because nothing else re-enables a monitor that a
|
||||
rule disabled — a re-applied monitor rule is accepted and ignored. That re-reads your Hyprland
|
||||
config, which is what puts your monitors back; the side effect is that any settings you changed at
|
||||
runtime with `hyprctl keyword` are dropped too, and a non-Lua config re-runs its `exec =` lines
|
||||
(`exec-once` is not re-run). This only happens if a session actually disabled something.
|
||||
|
||||
### Conflict handling · identity · layout
|
||||
|
||||
|
||||
Reference in New Issue
Block a user