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
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:
@@ -8,6 +8,10 @@
|
||||
//! * **KWin** names the streamed output `Virtual-punktfunk-<id>`; KWin persists per-output scale/mode
|
||||
//! in `kwinoutputconfig.json` matched by name, so a stable per-client name makes KDE reapply that
|
||||
//! client's scaling. (Generalised here from the Windows-only map; the KWin wiring is Stage 3.)
|
||||
//! * **Mutter** can't carry the id into its virtual monitor (fresh EDID serial per `RecordVirtual`,
|
||||
//! no API to override), so GNOME's `monitors.xml` never rematches — the host persists the scale
|
||||
//! itself instead ([`ScaleMap`], keyed by the same [`identity_key`]) and the Mutter backend
|
||||
//! reapplies it. The slot id still keys the registry's group arrangement/state.
|
||||
//!
|
||||
//! The map key is a composable string ([`identity_key`]): the client cert fingerprint alone
|
||||
//! (`per-client`), or fingerprint + resolution (`per-client-mode` — distinct scaling per resolution).
|
||||
@@ -149,8 +153,8 @@ impl DisplayIdentityMap {
|
||||
}
|
||||
|
||||
/// The process-wide identity map (persisted, loaded once). Shared by the Windows manager and the
|
||||
/// Linux KWin backend — never in the same process (a host runs one platform), so one instance ⇒ no
|
||||
/// clobbering of the shared `display-identity.json`.
|
||||
/// Linux KWin/Mutter backends — never in the same process (a host runs one platform), so one
|
||||
/// instance ⇒ no clobbering of the shared `display-identity.json`.
|
||||
pub(crate) fn global() -> &'static Mutex<DisplayIdentityMap> {
|
||||
static MAP: OnceLock<Mutex<DisplayIdentityMap>> = OnceLock::new();
|
||||
MAP.get_or_init(|| Mutex::new(DisplayIdentityMap::load()))
|
||||
@@ -184,6 +188,99 @@ pub(crate) fn resolve_slot(
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Per-client persisted desktop scale (`<config>/display-scale.json`) — the Mutter carrier
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// The scale-map filename.
|
||||
const SCALE_FILE: &str = "display-scale.json";
|
||||
|
||||
/// The key under which a client's desktop **scale** is persisted: the [`identity_key`] under a
|
||||
/// per-client policy, or the fixed `"shared"` slot under a Shared policy / for anonymous sessions
|
||||
/// (can't collide — identity keys are 64 hex chars). Same policy resolution as [`resolve_slot`].
|
||||
pub(crate) fn scale_key(
|
||||
fp: Option<[u8; 32]>,
|
||||
mode: (u32, u32),
|
||||
default: crate::vdisplay::policy::Identity,
|
||||
) -> String {
|
||||
let id_policy = crate::vdisplay::policy::prefs()
|
||||
.configured_effective()
|
||||
.map(|e| e.identity)
|
||||
.unwrap_or(default);
|
||||
scale_key_for(id_policy, fp, mode)
|
||||
}
|
||||
|
||||
/// Pure core of [`scale_key`] (policy already resolved) — unit-testable without the global store.
|
||||
fn scale_key_for(
|
||||
policy: crate::vdisplay::policy::Identity,
|
||||
fp: Option<[u8; 32]>,
|
||||
mode: (u32, u32),
|
||||
) -> String {
|
||||
use crate::vdisplay::policy::Identity;
|
||||
match (policy, fp) {
|
||||
(Identity::Shared, _) | (_, None) => "shared".to_string(),
|
||||
(Identity::PerClient, Some(fp)) => identity_key(fp, mode, false),
|
||||
(Identity::PerClientMode, Some(fp)) => identity_key(fp, mode, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent client-key → desktop-scale map. Windows and KDE persist per-display scaling
|
||||
/// themselves once the backend gives the monitor a stable identity — but GNOME **cannot**: Mutter
|
||||
/// mints a fresh EDID serial (`0x%.6x`, a per-shell counter) for every `RecordVirtual` monitor, so
|
||||
/// the `monitors.xml` entry GNOME writes never rematches on reconnect, and `RecordVirtual` offers
|
||||
/// no way to pass a stable identity. The host therefore remembers the scale itself; the Mutter
|
||||
/// backend reapplies it (`preferred-scale` + its topology `ApplyMonitorsConfig`) and records the
|
||||
/// user's in-session changes here.
|
||||
pub(crate) struct ScaleMap {
|
||||
path: PathBuf,
|
||||
map: std::collections::BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
impl ScaleMap {
|
||||
/// Load the persisted map (empty on first run / unreadable file — a client just re-sets its
|
||||
/// scaling once). Drops non-finite / out-of-range entries from a hand-edited file.
|
||||
fn load() -> Self {
|
||||
let path = crate::gamestream::config_dir().join(SCALE_FILE);
|
||||
let mut map: std::collections::BTreeMap<String, f64> = std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
map.retain(|_, s| s.is_finite() && (0.25..=8.0).contains(s));
|
||||
Self { path, map }
|
||||
}
|
||||
|
||||
/// The remembered scale for `key`, if any.
|
||||
pub(crate) fn get(&self, key: &str) -> Option<f64> {
|
||||
self.map.get(key).copied()
|
||||
}
|
||||
|
||||
/// Remember `scale` for `key` and persist (atomic temp-write + rename, best-effort — a failed
|
||||
/// write costs one scaling re-set after a restart).
|
||||
pub(crate) fn set(&mut self, key: &str, scale: f64) {
|
||||
if !scale.is_finite() || !(0.25..=8.0).contains(&scale) {
|
||||
return;
|
||||
}
|
||||
self.map.insert(key.to_string(), scale);
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&self.map) else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide scale map (persisted, loaded once) — used by the Mutter backend only (see
|
||||
/// [`ScaleMap`]).
|
||||
pub(crate) fn scales() -> &'static Mutex<ScaleMap> {
|
||||
static MAP: OnceLock<Mutex<ScaleMap>> = OnceLock::new();
|
||||
MAP.get_or_init(|| Mutex::new(ScaleMap::load()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,4 +340,46 @@ mod tests {
|
||||
assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only
|
||||
assert!(identity_key(fp(0xab), (1920, 1080), true).ends_with("@1920x1080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_key_follows_the_identity_policy() {
|
||||
use crate::vdisplay::policy::Identity;
|
||||
// Shared / anonymous → the fixed shared slot.
|
||||
assert_eq!(
|
||||
scale_key_for(Identity::Shared, Some(fp(1)), (1920, 1080)),
|
||||
"shared"
|
||||
);
|
||||
assert_eq!(
|
||||
scale_key_for(Identity::PerClient, None, (1920, 1080)),
|
||||
"shared"
|
||||
);
|
||||
// Per-client → the fingerprint key; per-client-mode appends the resolution.
|
||||
let pc = scale_key_for(Identity::PerClient, Some(fp(1)), (1920, 1080));
|
||||
assert_eq!(pc, identity_key(fp(1), (1920, 1080), false));
|
||||
let pcm = scale_key_for(Identity::PerClientMode, Some(fp(1)), (1920, 1080));
|
||||
assert!(pcm.ends_with("@1920x1080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_map_roundtrips_and_rejects_junk() {
|
||||
let path = std::env::temp_dir().join(format!("pf-scale-{}.json", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut m = ScaleMap {
|
||||
path: path.clone(),
|
||||
map: Default::default(),
|
||||
};
|
||||
assert_eq!(m.get("k"), None);
|
||||
m.set("k", 1.5);
|
||||
m.set("bad-nan", f64::NAN); // ignored
|
||||
m.set("bad-range", 100.0); // ignored
|
||||
assert_eq!(m.get("k"), Some(1.5));
|
||||
assert_eq!(m.get("bad-nan"), None);
|
||||
assert_eq!(m.get("bad-range"), None);
|
||||
// Persisted: a fresh map from the same path sees the value.
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let reread: std::collections::BTreeMap<String, f64> =
|
||||
serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(reread.get("k"), Some(&1.5));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user