topology: exclusive was echoed back by the API and dropped on Hyprland and sway #304

Merged
enricobuehler merged 1 commits from worktree-vdisplay-topology-wlroots-hyprland into main 2026-08-18 15:54:15 +00:00
6 changed files with 694 additions and 53 deletions
+373 -17
View File
@@ -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"
);
}
}
}
+278 -16
View File
@@ -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());
}
}
+9 -2
View File
@@ -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.
+6 -2
View File
@@ -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.
+4 -2
View File
@@ -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)
+24 -14
View File
@@ -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