fix(host): per-client display scaling on GNOME — host-persisted scale for Mutter virtual monitors
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 12s
apple / swift (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / docs-site (push) Successful in 1m24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 6m15s
apple / screenshots (push) Successful in 5m29s
windows-host / package (push) Successful in 8m43s
arch / build-publish (push) Successful in 11m29s
android / android (push) Successful in 13m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m47s
deb / build-publish (push) Successful in 16m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m12s
ci / rust (push) Successful in 22m39s
docker / deploy-docs (push) Successful in 17s

The identity policy promised per-client scaling, but on GNOME it could never
work through the DE: Mutter mints a fresh EDID serial (0x%.6x, a per-shell
counter) for every RecordVirtual monitor and offers no way to pass a stable
identity, so the monitors.xml entry GNOME writes when the user sets a scale
never rematches on reconnect — and our own topology ApplyMonitorsConfig then
clobbered whatever was left back to a hardcoded scale 1.0.

The host now remembers the scale itself (<config>/display-scale.json, keyed by
the identity key — fp / fp@WxH / "shared" per the policy): reapplied at connect
via the RecordVirtual mode's preferred-scale (Mutter pre-validates it; older
Mutter ignores the key) and preserved — not forced — by the topology apply,
with an integral-logical-size snap + retry-at-derived fallback (GetCurrentState
reports no supported-scales for virtual monitors to snap to). The user's
mid-session changes are polled from DisplayConfig every 5 s and written back,
so a host crash loses at most a few seconds; an unconfirmed "Keep Changes"
revert is tracked faithfully too. Mutter also now resolves the identity slot
(set_client_identity/last_identity_slot) like KWin, keying the registry's
group arrangement and /display/state.

Verified live on the GNOME 50 box: set 1.5 mid-stream → persisted under the
client fp; full teardown (fresh serial 0x000018); reconnect → monitor up at
1.5, "reapplying the client's saved display scale" logged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:58:45 +02:00
parent 4c99b78366
commit c8fdef8bd1
3 changed files with 465 additions and 92 deletions
+8 -6
View File
@@ -117,10 +117,12 @@ pub trait VirtualDisplay: Send {
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
fn set_launch_command(&mut self, _cmd: Option<String>) {}
/// Set the connecting client's cert fingerprint so the backend can give that client a STABLE virtual
/// monitor identity across reconnects — Windows then reapplies the client's saved per-monitor config
/// (notably DPI scaling). Carried on the backend instance; set once before [`create`](Self::create).
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
/// monitor identity across reconnects and its saved per-monitor config (notably DPI scaling) is
/// reapplied — via the OS (Windows EDID serial), the compositor (KWin per-slot output name), or
/// host-side persistence (Mutter, whose virtual monitors can't carry a stable identity). Carried on
/// the backend instance; set once before [`create`](Self::create). Default: no-op (wlroots/gamescope
/// have no per-client identity). `None` = anonymous/unpaired/GameStream → the backend's auto
/// (slot-based/shared) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
/// Hand the backend the session's deliberate-quit flag (set when the client closes with the QUIT
/// application code — a user "stop", not a network drop) so the last lease's drop can tear the
@@ -134,8 +136,8 @@ pub trait VirtualDisplay: Send {
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
/// registry reads it right after `create` to key the display's group **arrangement** (manual
/// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend
/// with no per-client identity (Mutter/wlroots/gamescope) always auto-rows. Only KWin (per-slot
/// output naming) reports a real slot on Linux.
/// with no per-client identity (wlroots/gamescope) always auto-rows. KWin (per-slot output
/// naming) and Mutter (host-persisted per-client scale) report a real slot on Linux.
fn last_identity_slot(&self) -> Option<u32> {
None
}