Compare commits

...
Author SHA1 Message Date
enricobuehlerandClaude Opus 5 362595b20f fix(vdisplay/kwin): the streamed output declares it mirrors nothing, so a stored replicationSource can't clone a panel into the stream
apple / swift (pull_request) Successful in 1m25s
ci / rust-arm64 (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m13s
ci / rust (pull_request) Successful in 4m22s
ci / docs-site (pull_request) Failing after 18m2s
ci / web (pull_request) Failing after 18m8s
ci / rust (push) Failing after 3s
ci / rust-arm64 (push) Failing after 5s
deb / build-publish-host (push) Failing after 3s
deb / build-publish-client-arm64 (push) Failing after 3s
apple / swift (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m24s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 4m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 2s
docker / builders-arm64cross (push) Successful in 21s
android / android (push) Successful in 6m2s
docker / deploy-docs (push) Successful in 1m14s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m11s
deb / build-publish (push) Successful in 5m4s
ci / web (push) Failing after 10m16s
windows-host / package (push) Successful in 11m15s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 10m25s
KWin stores output configuration per *setup* — the exact set of connected
outputs, matched by EDID/connector — in `kwinoutputconfig.json`, and
`replicationSource` is one of the fields it saves and restores
(`OutputConfigurationStore::storeConfig` / `setupToConfig`). Our virtual output
carries a STABLE name on purpose, so once any setup has an entry making
`Virtual-punktfunk` a mirror of a physical head, KWin re-applies it to OUR
output on every session that reproduces that same monitor set — and only that
set, which is why the failure looks environment-dependent: a field report has
the stream cloning the panel whenever exactly one monitor is live, and behaving
normally the moment the others come back (a different setup key, a different
stored entry).

A mirroring output is not a desktop. KWin's `applyMirroring` overrides its scale
and render offset to the source's, so the stream carries the physical screen's
viewport at the physical screen's size instead of the mode the client
negotiated. The protocol says the rest out loud on `priority`: "an output may
not be in the output order if it's disabled or mirroring another screen" — so
the primary assertion this module works so hard to verify silently stops meaning
anything too.

Nothing we sent ever contradicted the stored value. The topology config enabled
our output, took priority 1 and disabled the others, but never stated the one
property that decides whether the thing is its own screen. Now it does:
`set_replication_source(ours, "")` rides along in the config we already build
(free, idempotent — an empty source is exactly what KWin resolves to "mirrors
nothing"), gated on management v13 where the request appeared, since wayland-rs
does not range-check requests and an out-of-range opcode would kill the
connection.

`extend`/`auto` issue no topology calls by design — the streamed output is meant
to join the desk without rearranging it — but a mirror is not an arrangement, it
is a broken source under every topology. So they get `clear_replication_source`,
which enumerates and applies ONLY when our output really is mirroring.

The device's `replication_source` event is now read, so the state is visible: a
mirrored streamed output names its source in a warn instead of leaving "the
stream just shows my monitor" as something only the reporter can see.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96): `cargo test -p pf-vdisplay` 128
pass (7 in `kwin_output_mgmt`), `cargo clippy -p pf-vdisplay --all-targets
--locked -D warnings` clean, `scripts/xcheck.sh linux` clean, fmt clean. NOT yet
on-glass — no KDE box here reproduces a stored mirror; the reporter's setup is
the real test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:55:34 +02:00
2 changed files with 155 additions and 1 deletions
@@ -138,7 +138,14 @@ impl KwinDisplay {
let kind = match topology {
Topology::Exclusive => TopologyKind::Exclusive,
Topology::Primary => TopologyKind::Primary,
Topology::Extend | Topology::Auto => return Vec::new(),
Topology::Extend | Topology::Auto => {
// No topology to apply — but the output must still be its OWN desktop rather than a
// mirror of someone's panel, and KWin restores a stored `replicationSource` onto our
// (stable) output name for whatever monitor set it was saved under. Applies only if
// it really is mirroring; nothing else about the user's arrangement is touched.
crate::kwin_output_mgmt::clear_replication_source(our_prefix, dims.0, dims.1);
return Vec::new();
}
};
// In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
@@ -112,6 +112,34 @@ const POLL_MS: i32 = 100;
/// asked — matches `kwin::CVT_H_GRANULARITY`. Used when matching the generated mode back.
const CVT_H_GRANULARITY: u32 = 8;
/// `kde_output_management_v2.set_replication_source` (and the device's `replication_source` event)
/// arrived in v13. wayland-rs does not range-check requests, so sending one to a lower-version bind
/// would be a protocol error that kills the connection — every call site gates on this.
const REPLICATION_SOURCE_SINCE: u32 = 13;
/// The `source` value that means "this output mirrors nothing" — KWin's `applyMirroring` looks the
/// source UUID up among the enabled outputs and treats an EMPTY string as no replication at all.
const NO_REPLICATION_SOURCE: &str = "";
/// Is this output currently a MIRROR of another one?
///
/// KWin persists output config per *setup* — the exact set of connected outputs, matched by
/// EDID/connector — in `kwinoutputconfig.json`, and `replicationSource` is one of the fields it
/// stores and restores (`OutputConfigurationStore::storeConfig` / `setupToConfig`). Our virtual
/// output carries a STABLE name across sessions (that is deliberate — KWin keys per-output scale by
/// it), so a stored `replicationSource` for that name is re-applied to OUR output on every session
/// that reproduces the same monitor set. The output then shows the source's viewport instead of
/// being its own desktop, which is the whole point of creating it — and per the protocol's own note
/// on `priority`, "an output may not be in the output order if it's disabled **or mirroring another
/// screen**", so the primary assertion silently stops meaning anything too.
///
/// The event carries an empty string for the ordinary case, so `Some("")` must read as "not
/// mirroring" — treating the mere presence of the event as a mirror would de-mirror every output on
/// every apply.
fn is_mirroring(replication_source: Option<&str>) -> bool {
replication_source.is_some_and(|s| !s.is_empty())
}
/// Which topology to apply once our output is resolved.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum TopologyKind {
@@ -154,6 +182,9 @@ struct DeviceState {
scale: Option<f64>,
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
priority: Option<u32>,
/// UUID of the output this one MIRRORS, from the `replication_source` event (device ≥ v13).
/// Empty / `None` ⇒ it is its own desktop. See [`is_mirroring`] for why this matters to us.
replication_source: Option<String>,
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
current_mode: Option<ObjectId>,
/// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
@@ -307,6 +338,7 @@ impl Dispatch<OutputDevice, u32> for State {
DeviceEvent::Scale { factor } => entry.scale = Some(factor),
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
DeviceEvent::ReplicationSource { source } => entry.replication_source = Some(source),
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
DeviceEvent::Done => entry.seen_done = true,
@@ -626,6 +658,17 @@ pub(crate) fn apply_topology(
};
let our_uuid = ours.uuid.clone();
let our_id = ours.proxy.as_ref().map(|p| p.id());
if is_mirroring(ours.replication_source.as_deref()) {
// Worth a line of its own: this is the state a user experiences as "the stream just shows my
// monitor", and it comes from KWin's stored config for THIS monitor set, so it reproduces
// every session until something clears it. The config below does.
tracing::warn!(
source_uuid = ?ours.replication_source,
our_prefix,
"KWin had our streamed output MIRRORING another screen (a stored kwinoutputconfig.json \
replicationSource for this monitor set) — clearing it so the output is its own desktop"
);
}
// First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
// (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop. A
@@ -681,6 +724,17 @@ pub(crate) fn apply_topology(
let config = sess.new_config();
if let Some(proxy) = ours.proxy.as_ref() {
config.enable(proxy, 1);
// State that ours is its OWN desktop, not a replica of somebody's panel. A stored
// `replicationSource` for our (stable) output name is re-applied by KWin on every session
// that reproduces the same monitor set, and it survives everything else this config says:
// enabling and prioritising a mirror still leaves it showing the source's viewport, scaled
// to the source's size (`OutputConfigurationStore::applyMirroring`). See [`is_mirroring`].
// Unconditional rather than conditional on what we enumerated: KWin may apply the stored
// setup config between our enumerate and this apply, and clearing a source that is already
// empty is exactly what KWin does for a non-mirroring output anyway.
if mgmt_version >= REPLICATION_SOURCE_SINCE {
config.set_replication_source(proxy, NO_REPLICATION_SOURCE.to_string());
}
if !sibling_is_primary {
config.set_primary_output(proxy);
if mgmt_version >= 3 {
@@ -781,6 +835,68 @@ pub(crate) fn apply_topology(
}
}
/// De-mirror the just-created virtual output (name starts with `our_prefix`, current size
/// `our_w`×`our_h`) **without touching the rest of the topology** — the `Extend`/`Auto` counterpart
/// to the clear [`apply_topology`] folds into its own config.
///
/// Those topologies deliberately issue no output-management calls: the streamed output is meant to
/// join the desk as one more head, and re-arranging the user's screens would be the rudeness the
/// setting exists to avoid. But a stored `replicationSource` (see [`is_mirroring`]) is not an
/// arrangement — it makes our output show a *physical panel's* viewport instead of its own desktop,
/// which is broken under every topology equally. So this reads the state and applies **only** when
/// our output really is mirroring; the ordinary session pays one bounded enumerate and no apply.
pub(crate) fn clear_replication_source(our_prefix: &str, our_w: u32, our_h: u32) {
let Some(mut sess) = Session::open() else {
return;
};
let deadline = Instant::now() + OP_BUDGET;
let mgmt_version = sess
.state
.mgmt_name_version
.map(|(_, v)| v)
.unwrap_or_default();
if mgmt_version < REPLICATION_SOURCE_SINCE {
return;
}
// Same resolve as `apply_topology`: managed-prefix name AND the birth size, newest global wins.
let Some(ours) = sess
.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
})
.max_by_key(|d| d.global)
.cloned()
else {
return;
};
if !is_mirroring(ours.replication_source.as_deref()) {
return;
}
let Some(proxy) = ours.proxy.as_ref() else {
return;
};
tracing::warn!(
source_uuid = ?ours.replication_source,
our_prefix,
"KWin had our streamed output MIRRORING another screen (a stored kwinoutputconfig.json \
replicationSource for this monitor set) — clearing it so the output is its own desktop"
);
let config = sess.new_config();
config.set_replication_source(proxy, NO_REPLICATION_SOURCE.to_string());
let ok = sess.apply(&config, deadline);
config.destroy();
if !ok {
tracing::warn!(
reason = ?sess.state.failure_reason,
"KWin output management: could not clear the streamed output's replication source — \
the stream will show the mirrored screen's content"
);
}
}
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
@@ -1010,6 +1126,37 @@ fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option<DeviceMode
mod tests {
use super::*;
/// KWin sends `replication_source` with an EMPTY string for the ordinary, non-mirroring output.
/// Reading the event's mere presence as "mirroring" would make every apply issue a pointless
/// de-mirror — and, worse, would make the warn fire on every healthy session.
#[test]
fn an_empty_replication_source_is_not_mirroring() {
assert!(!is_mirroring(None));
assert!(!is_mirroring(Some("")));
}
/// A real source UUID is the state the field report describes: the streamed output shows a
/// physical panel's viewport instead of its own desktop.
#[test]
fn a_uuid_replication_source_is_mirroring() {
assert!(is_mirroring(Some("f7a3c1e2-0b44-4c19-9a1d-6f2b8e0c5d31")));
}
/// The clear we send must be the value KWin reads as "mirrors nothing" — an empty source, which
/// its `applyMirroring` fails to resolve to any enabled output and so treats as no replication.
#[test]
fn the_clear_value_is_the_empty_source() {
assert!(!is_mirroring(Some(NO_REPLICATION_SOURCE)));
}
/// The request/event pair is `since 13`; wayland-rs does not range-check requests, so a bind
/// below this must never reach `set_replication_source` (it would be a fatal protocol error).
#[test]
fn replication_source_version_gate_matches_the_protocol() {
assert_eq!(REPLICATION_SOURCE_SINCE, 13);
const { assert!(MGMT_MAX >= REPLICATION_SOURCE_SINCE) };
}
/// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
#[test]
fn mode_spec_rounds_millihertz() {