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:
@@ -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).
|
/// 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>) {}
|
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
|
/// 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
|
/// monitor identity across reconnects and its saved per-monitor config (notably DPI scaling) is
|
||||||
/// (notably DPI scaling). Carried on the backend instance; set once before [`create`](Self::create).
|
/// reapplied — via the OS (Windows EDID serial), the compositor (KWin per-slot output name), or
|
||||||
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
|
/// host-side persistence (Mutter, whose virtual monitors can't carry a stable identity). Carried on
|
||||||
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
|
/// 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]>) {}
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// with no per-client identity (wlroots/gamescope) always auto-rows. KWin (per-slot output
|
||||||
/// output naming) reports a real slot on Linux.
|
/// naming) and Mutter (host-persisted per-client scale) report a real slot on Linux.
|
||||||
fn last_identity_slot(&self) -> Option<u32> {
|
fn last_identity_slot(&self) -> Option<u32> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
//! * **KWin** names the streamed output `Virtual-punktfunk-<id>`; KWin persists per-output scale/mode
|
//! * **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
|
//! 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.)
|
//! 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
|
//! 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).
|
//! (`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
|
/// 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
|
/// Linux KWin/Mutter backends — never in the same process (a host runs one platform), so one
|
||||||
/// clobbering of the shared `display-identity.json`.
|
/// instance ⇒ no clobbering of the shared `display-identity.json`.
|
||||||
pub(crate) fn global() -> &'static Mutex<DisplayIdentityMap> {
|
pub(crate) fn global() -> &'static Mutex<DisplayIdentityMap> {
|
||||||
static MAP: OnceLock<Mutex<DisplayIdentityMap>> = OnceLock::new();
|
static MAP: OnceLock<Mutex<DisplayIdentityMap>> = OnceLock::new();
|
||||||
MAP.get_or_init(|| Mutex::new(DisplayIdentityMap::load()))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -243,4 +340,46 @@ mod tests {
|
|||||||
assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only
|
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"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@
|
|||||||
//! Requires a running Mutter (`gnome-shell` session, or `gnome-shell --headless` for the
|
//! Requires a running Mutter (`gnome-shell` session, or `gnome-shell --headless` for the
|
||||||
//! headless host) on the session bus. GNOME is detected via `XDG_CURRENT_DESKTOP=GNOME` or
|
//! headless host) on the session bus. GNOME is detected via `XDG_CURRENT_DESKTOP=GNOME` or
|
||||||
//! forced with `PUNKTFUNK_COMPOSITOR=mutter`.
|
//! forced with `PUNKTFUNK_COMPOSITOR=mutter`.
|
||||||
|
//!
|
||||||
|
//! **Per-client scaling** (`identity` policy §5.4): GNOME persists per-monitor scale to
|
||||||
|
//! `monitors.xml` keyed by connector+vendor+product+**serial**, but Mutter mints a fresh serial
|
||||||
|
//! (`0x%.6x`, a per-shell counter) for every `RecordVirtual` monitor and the API offers no way to
|
||||||
|
//! pass a stable identity — so GNOME's own persistence can never rematch our virtual output. The
|
||||||
|
//! host persists the scale instead ([`identity::ScaleMap`](crate::vdisplay::identity), keyed per
|
||||||
|
//! client / per the policy): reapplied at connect via the mode's `preferred-scale` plus the
|
||||||
|
//! topology `ApplyMonitorsConfig`, and the user's mid-session changes are polled from
|
||||||
|
//! DisplayConfig and written back.
|
||||||
|
|
||||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
@@ -48,12 +57,23 @@ pub struct MutterDisplay {
|
|||||||
/// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
|
/// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
|
||||||
/// session establishes topology as before).
|
/// session establishes topology as before).
|
||||||
first_in_group: bool,
|
first_in_group: bool,
|
||||||
|
/// The connecting client's cert fingerprint (set before [`create`](VirtualDisplay::create)) —
|
||||||
|
/// keys the per-client persisted **scale** (GNOME can't persist it itself: Mutter mints a fresh
|
||||||
|
/// EDID serial per `RecordVirtual` monitor, so `monitors.xml` never rematches; see
|
||||||
|
/// [`identity::ScaleMap`](crate::vdisplay::identity)).
|
||||||
|
client_fp: Option<[u8; 32]>,
|
||||||
|
/// The identity slot the last `create` resolved — reported to the registry via
|
||||||
|
/// [`last_identity_slot`](VirtualDisplay::last_identity_slot) to key the group arrangement +
|
||||||
|
/// `/display/state` slot, like the KWin backend.
|
||||||
|
last_slot: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MutterDisplay {
|
impl MutterDisplay {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Ok(MutterDisplay {
|
Ok(MutterDisplay {
|
||||||
first_in_group: true,
|
first_in_group: true,
|
||||||
|
client_fp: None,
|
||||||
|
last_slot: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,14 +96,54 @@ impl VirtualDisplay for MutterDisplay {
|
|||||||
self.first_in_group = first;
|
self.first_in_group = first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
|
||||||
|
self.client_fp = fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_identity_slot(&self) -> Option<u32> {
|
||||||
|
self.last_slot
|
||||||
|
}
|
||||||
|
|
||||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||||
|
// Identity (§5.4): resolve the client's stable slot per the `identity` policy (Linux
|
||||||
|
// defaults to Shared when unconfigured, like KWin) — it keys the registry's group
|
||||||
|
// arrangement/state. Mutter can't carry the slot into the monitor's EDID (RecordVirtual
|
||||||
|
// owns the identity), so the per-client scaling that policy promises is host-persisted
|
||||||
|
// instead: the session thread reapplies the remembered scale and records the user's
|
||||||
|
// in-session changes under `scale_key`.
|
||||||
|
self.last_slot = crate::vdisplay::identity::resolve_slot(
|
||||||
|
self.client_fp,
|
||||||
|
(mode.width, mode.height),
|
||||||
|
crate::vdisplay::policy::Identity::Shared,
|
||||||
|
);
|
||||||
|
let scale_key = crate::vdisplay::identity::scale_key(
|
||||||
|
self.client_fp,
|
||||||
|
(mode.width, mode.height),
|
||||||
|
crate::vdisplay::policy::Identity::Shared,
|
||||||
|
);
|
||||||
|
let remembered_scale = crate::vdisplay::identity::scales()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&scale_key);
|
||||||
|
if let Some(scale) = remembered_scale {
|
||||||
|
tracing::info!(scale, "mutter: reapplying the client's saved display scale");
|
||||||
|
}
|
||||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let stop_thread = stop.clone();
|
let stop_thread = stop.clone();
|
||||||
let first_in_group = self.first_in_group;
|
let first_in_group = self.first_in_group;
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("punktfunk-mutter-vout".into())
|
.name("punktfunk-mutter-vout".into())
|
||||||
.spawn(move || session_thread(setup_tx, stop_thread, mode, first_in_group))
|
.spawn(move || {
|
||||||
|
session_thread(
|
||||||
|
setup_tx,
|
||||||
|
stop_thread,
|
||||||
|
mode,
|
||||||
|
first_in_group,
|
||||||
|
scale_key,
|
||||||
|
remembered_scale,
|
||||||
|
)
|
||||||
|
})
|
||||||
.context("spawn Mutter virtual-output thread")?;
|
.context("spawn Mutter virtual-output thread")?;
|
||||||
|
|
||||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||||
@@ -118,11 +178,16 @@ impl Drop for StopGuard {
|
|||||||
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
|
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
|
||||||
/// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a
|
/// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a
|
||||||
/// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it).
|
/// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it).
|
||||||
|
/// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect,
|
||||||
|
/// and the user's in-session changes are recorded back under the key (GNOME itself can't — see
|
||||||
|
/// [`identity::ScaleMap`](crate::vdisplay::identity)).
|
||||||
fn session_thread(
|
fn session_thread(
|
||||||
setup_tx: Sender<Result<u32, String>>,
|
setup_tx: Sender<Result<u32, String>>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
first_in_group: bool,
|
first_in_group: bool,
|
||||||
|
scale_key: String,
|
||||||
|
remembered_scale: Option<f64>,
|
||||||
) {
|
) {
|
||||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
.worker_threads(1)
|
.worker_threads(1)
|
||||||
@@ -157,27 +222,25 @@ fn session_thread(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let exclusive = matches!(topo, Topology::Exclusive);
|
let exclusive = matches!(topo, Topology::Exclusive);
|
||||||
// Snapshot the monitor layout BEFORE the virtual output exists (so we can tell the new
|
// Snapshot the monitor layout BEFORE the virtual output exists — it's how we tell the new
|
||||||
// connector apart and restore on teardown) whenever we're going to touch the topology.
|
// connector apart, both for the topology apply and for tracking the scale the user sets on
|
||||||
let dc_pre = if want_config {
|
// it. Taken unconditionally now (scale tracking wants it even when we won't touch the
|
||||||
match display_config().await {
|
// topology); failure just degrades to no-topology + no-scale-persistence, as before.
|
||||||
|
let dc_pre = match display_config().await {
|
||||||
Ok(dc) => match get_state(&dc).await {
|
Ok(dc) => match get_state(&dc).await {
|
||||||
Ok(state) => Some((dc, state)),
|
Ok(state) => Some((dc, state)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("mutter: GetCurrentState (pre) failed ({e:#}); leaving displays as-is");
|
tracing::warn!("mutter: GetCurrentState (pre) failed ({e:#}); topology + scale persistence off");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("mutter: DisplayConfig unavailable ({e:#}); leaving displays as-is");
|
tracing::warn!("mutter: DisplayConfig unavailable ({e:#}); topology + scale persistence off");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let session = match connect(mode).await {
|
let session = match connect(mode, remembered_scale).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||||
@@ -186,12 +249,27 @@ fn session_thread(
|
|||||||
};
|
};
|
||||||
let _ = setup_tx.send(Ok(session.node_id));
|
let _ = setup_tx.send(Ok(session.node_id));
|
||||||
|
|
||||||
// Make the freshly-created virtual output the PRIMARY monitor so the GNOME shell + new
|
// Identify the virtual connector (present now, absent in the pre-snapshot), then — when this
|
||||||
// windows land on the surface we stream. Without this, on a host that also has a physical
|
// session owns the topology — make it the PRIMARY monitor so the GNOME shell + new windows
|
||||||
// monitor attached, the virtual output is an empty extended desktop — you stream only the
|
// land on the surface we stream. Without this, on a host that also has a physical monitor
|
||||||
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
|
// attached, the virtual output is an empty extended desktop — you stream only the wallpaper.
|
||||||
if let Some((dc, pre)) = &dc_pre {
|
// Best-effort: any failure just logs and streaming continues unchanged.
|
||||||
match make_virtual_primary(dc, mode, pre, exclusive).await {
|
let mut tracked: Option<(zbus::Proxy<'static>, CurrentState, String)> = None;
|
||||||
|
if let Some((dc, pre)) = dc_pre {
|
||||||
|
match wait_virtual_connector(&dc, &pre).await {
|
||||||
|
Ok((vconn, state)) => {
|
||||||
|
if want_config {
|
||||||
|
match make_virtual_primary(
|
||||||
|
&dc,
|
||||||
|
mode,
|
||||||
|
&pre,
|
||||||
|
&state,
|
||||||
|
&vconn,
|
||||||
|
exclusive,
|
||||||
|
remembered_scale,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => tracing::info!(
|
Ok(()) => tracing::info!(
|
||||||
exclusive,
|
exclusive,
|
||||||
"mutter: virtual output set as the primary monitor (physicals {})",
|
"mutter: virtual output set as the primary monitor (physicals {})",
|
||||||
@@ -202,10 +280,32 @@ fn session_thread(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracked = Some((dc, pre, vconn));
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"mutter: virtual connector not identified ({e:#}); topology + scale persistence off"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Park, keeping `session` (and its zbus connection) alive until told to stop.
|
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
|
||||||
|
// read the virtual output's logical-monitor scale and persist a change the user made (GNOME
|
||||||
|
// Settings mid-stream) under the client's key — polled rather than teardown-only so a host
|
||||||
|
// crash/redeploy doesn't lose it.
|
||||||
|
let mut known = remembered_scale.unwrap_or(1.0);
|
||||||
|
let mut ticks: u32 = 0;
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
ticks = ticks.wrapping_add(1);
|
||||||
|
if ticks % 25 == 0 {
|
||||||
|
if let Some((dc, _, vconn)) = &tracked {
|
||||||
|
persist_scale_change(dc, vconn, &scale_key, &mut known).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final scale read BEFORE Stop (the virtual output must still exist to be read).
|
||||||
|
if let Some((dc, _, vconn)) = &tracked {
|
||||||
|
persist_scale_change(dc, vconn, &scale_key, &mut known).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down: STOP the screencast so Mutter removes the virtual output. We deliberately do NOT
|
// Tear down: STOP the screencast so Mutter removes the virtual output. We deliberately do NOT
|
||||||
@@ -215,10 +315,10 @@ fn session_thread(
|
|||||||
// returned "recipient disconnected from message bus" because the shell crashed mid-call, after
|
// returned "recipient disconnected from message bus" because the shell crashed mid-call, after
|
||||||
// which GDM's crash-loop guard dropped to the greeter and wedged EVERY subsequent reconnect.
|
// which GDM's crash-loop guard dropped to the greeter and wedged EVERY subsequent reconnect.
|
||||||
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
||||||
// the virtual output disappears and our DisplayConfig connection (`dc_pre`) closes — so we just
|
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
|
||||||
// drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
||||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||||
drop(dc_pre);
|
drop(tracked);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +330,11 @@ struct MutterSession {
|
|||||||
node_id: u32,
|
node_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the four-step handshake (see module docs).
|
/// Run the four-step handshake (see module docs). `preferred_scale` is the client's remembered
|
||||||
async fn connect(mode: Mode) -> Result<MutterSession> {
|
/// desktop scale, passed as the virtual mode's `preferred-scale` so Mutter creates the monitor
|
||||||
|
/// already scaled (Mutter ≥ 48; older Mutter ignores unknown mode keys) — this covers the
|
||||||
|
/// `extend` topology, where we never issue our own ApplyMonitorsConfig.
|
||||||
|
async fn connect(mode: Mode, preferred_scale: Option<f64>) -> Result<MutterSession> {
|
||||||
let conn = zbus::Connection::session()
|
let conn = zbus::Connection::session()
|
||||||
.await
|
.await
|
||||||
.context("connect session D-Bus")?;
|
.context("connect session D-Bus")?;
|
||||||
@@ -293,10 +396,17 @@ async fn connect(mode: Mode) -> Result<MutterSession> {
|
|||||||
// reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
|
// reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
|
||||||
let mut rec: HashMap<&str, Value> = HashMap::new();
|
let mut rec: HashMap<&str, Value> = HashMap::new();
|
||||||
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
|
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
|
||||||
if mode.refresh_hz > 60 {
|
if mode.refresh_hz > 60 || preferred_scale.is_some() {
|
||||||
let mut vmode: HashMap<&str, Value> = HashMap::new();
|
let mut vmode: HashMap<&str, Value> = HashMap::new();
|
||||||
vmode.insert("size", Value::from((mode.width, mode.height)));
|
vmode.insert("size", Value::from((mode.width, mode.height)));
|
||||||
|
// Only pin the refresh when it buys something (see above) — a remembered scale alone
|
||||||
|
// rides Mutter's 60 Hz default, exactly like the no-modes path did.
|
||||||
|
if mode.refresh_hz > 60 {
|
||||||
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
|
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
|
||||||
|
}
|
||||||
|
if let Some(scale) = preferred_scale {
|
||||||
|
vmode.insert("preferred-scale", Value::from(scale));
|
||||||
|
}
|
||||||
vmode.insert("is-preferred", Value::from(true));
|
vmode.insert("is-preferred", Value::from(true));
|
||||||
rec.insert("modes", Value::from(vec![vmode]));
|
rec.insert("modes", Value::from(vec![vmode]));
|
||||||
}
|
}
|
||||||
@@ -501,26 +611,45 @@ fn physical_keep_mode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
|
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
|
||||||
/// which lands shortly after the node id), then make it the SOLE primary output (physicals
|
/// which lands shortly after the node id) and return its connector name (present now, absent in
|
||||||
/// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
|
/// the pre-snapshot) plus the state that contained it.
|
||||||
/// surface. Restored on teardown.
|
async fn wait_virtual_connector(
|
||||||
async fn make_virtual_primary(
|
|
||||||
dc: &zbus::Proxy<'_>,
|
dc: &zbus::Proxy<'_>,
|
||||||
mode: Mode,
|
|
||||||
pre: &CurrentState,
|
pre: &CurrentState,
|
||||||
exclusive: bool,
|
) -> Result<(String, CurrentState)> {
|
||||||
) -> Result<()> {
|
|
||||||
let pre_conns = connectors(pre);
|
let pre_conns = connectors(pre);
|
||||||
let deadline = Instant::now() + Duration::from_secs(6);
|
let deadline = Instant::now() + Duration::from_secs(6);
|
||||||
loop {
|
loop {
|
||||||
let state = get_state(dc).await?;
|
let state = get_state(dc).await?;
|
||||||
// The virtual connector = present now, absent in the pre-snapshot.
|
|
||||||
let virt = state
|
let virt = state
|
||||||
.1
|
.1
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| m.0 .0.clone())
|
.map(|m| m.0 .0.clone())
|
||||||
.find(|c| !pre_conns.contains(c));
|
.find(|c| !pre_conns.contains(c));
|
||||||
if let Some(vconn) = virt {
|
if let Some(vconn) = virt {
|
||||||
|
return Ok((vconn, state));
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
bail!("the virtual monitor did not appear in DisplayConfig within 6s");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the virtual output the primary output — SOLE (`exclusive`: physicals disabled for the
|
||||||
|
/// session) or with the physicals kept as secondaries — so the cursor, windows, and keyboard focus
|
||||||
|
/// stay on the streamed surface. Applied at the client's `remembered_scale` (validated against the
|
||||||
|
/// mode's supported scales; 1.0 when none is remembered) so a saved DPI setting survives the
|
||||||
|
/// reconnect. Reverted by Mutter on teardown (APPLY_TEMPORARY).
|
||||||
|
async fn make_virtual_primary(
|
||||||
|
dc: &zbus::Proxy<'_>,
|
||||||
|
mode: Mode,
|
||||||
|
pre: &CurrentState,
|
||||||
|
state: &CurrentState,
|
||||||
|
vconn: &str,
|
||||||
|
exclusive: bool,
|
||||||
|
remembered_scale: Option<f64>,
|
||||||
|
) -> Result<()> {
|
||||||
// Prefer the mode matching the client's WxH; fall back to whatever is current.
|
// Prefer the mode matching the client's WxH; fall back to whatever is current.
|
||||||
let vmode = state
|
let vmode = state
|
||||||
.1
|
.1
|
||||||
@@ -531,19 +660,37 @@ async fn make_virtual_primary(
|
|||||||
.find(|md| md.1 == mode.width as i32 && md.2 == mode.height as i32)
|
.find(|md| md.1 == mode.width as i32 && md.2 == mode.height as i32)
|
||||||
.map(|md| md.0.clone())
|
.map(|md| md.0.clone())
|
||||||
})
|
})
|
||||||
.or_else(|| current_mode(&state, &vconn).map(|(id, _, _)| id));
|
.or_else(|| current_mode(state, vconn).map(|(id, _, _)| id));
|
||||||
let Some(vmode) = vmode else {
|
let Some(vmode) = vmode else {
|
||||||
bail!("virtual monitor {vconn} has no usable mode yet");
|
bail!("virtual monitor {vconn} has no usable mode yet");
|
||||||
};
|
};
|
||||||
|
// The scale to apply. Mutter (≥ its `preferred-scale` support) already derived the virtual's
|
||||||
|
// logical monitor at the remembered scale we passed to RecordVirtual, PRE-VALIDATED — preserve
|
||||||
|
// that instead of forcing a value (forcing 1.0 here was the original scale-clobber bug). On an
|
||||||
|
// older Mutter the derived scale stays 1.0 while a scale is remembered — try the remembered
|
||||||
|
// value snapped to an integral logical size (Mutter's fractional-scaling validity rule;
|
||||||
|
// GetCurrentState reports NO supported-scales for virtual monitors to snap to), and retry at
|
||||||
|
// the derived scale if the whole apply is rejected (an invalid scale fails the entire config —
|
||||||
|
// losing the primary switch over scaling would be worse).
|
||||||
|
let derived = logical_scale(state, vconn)
|
||||||
|
.filter(|s| s.is_finite() && *s > 0.0)
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
let mut scale = match remembered_scale {
|
||||||
|
Some(want) if (want - derived).abs() > 1e-3 => {
|
||||||
|
snap_integral_scale(want, mode.width, mode.height)
|
||||||
|
}
|
||||||
|
_ => derived,
|
||||||
|
};
|
||||||
|
loop {
|
||||||
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
|
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
|
||||||
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
|
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
|
||||||
// (On a headless host with no physicals the two are identical.)
|
// (On a headless host with no physicals the two are identical.)
|
||||||
let config = if exclusive {
|
let config = if exclusive {
|
||||||
build_exclusive_config(&vconn, &vmode)
|
build_exclusive_config(vconn, &vmode, scale)
|
||||||
} else {
|
} else {
|
||||||
build_primary_keeping_physicals(pre, &state, &vconn, &vmode, mode.width as i32)
|
build_primary_keeping_physicals(pre, state, vconn, &vmode, mode.width as i32, scale)
|
||||||
};
|
};
|
||||||
let _: () = dc
|
let res: zbus::Result<()> = dc
|
||||||
.call(
|
.call(
|
||||||
"ApplyMonitorsConfig",
|
"ApplyMonitorsConfig",
|
||||||
&(
|
&(
|
||||||
@@ -553,14 +700,71 @@ async fn make_virtual_primary(
|
|||||||
HashMap::<String, Value<'static>>::new(),
|
HashMap::<String, Value<'static>>::new(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.context("DisplayConfig.ApplyMonitorsConfig (set virtual primary)")?;
|
match res {
|
||||||
return Ok(());
|
Ok(()) => return Ok(()),
|
||||||
|
Err(e) if (scale - derived).abs() > 1e-3 => {
|
||||||
|
tracing::warn!(
|
||||||
|
scale,
|
||||||
|
derived,
|
||||||
|
"mutter: ApplyMonitorsConfig at the remembered scale failed ({e:#}); retrying at the derived scale"
|
||||||
|
);
|
||||||
|
scale = derived;
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
Err(e) => {
|
||||||
bail!("the virtual monitor did not appear in DisplayConfig within 6s");
|
return Err(e).context("DisplayConfig.ApplyMonitorsConfig (set virtual primary)")
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snap `want` to the nearest scale that gives the mode an **integral logical size** — Mutter only
|
||||||
|
/// accepts fractional scales where both `width/scale` and `height/scale` are integers, and its
|
||||||
|
/// GetCurrentState reports no `supported-scales` for virtual monitors to snap to. Searches the few
|
||||||
|
/// logical widths around the target for one that keeps the aspect exact; falls back to `want`
|
||||||
|
/// unchanged (the caller retries at the derived scale if Mutter still rejects it). Pure, unit-tested.
|
||||||
|
fn snap_integral_scale(want: f64, width: u32, height: u32) -> f64 {
|
||||||
|
if !want.is_finite() || want <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let (w, h) = (width as i64, height as i64);
|
||||||
|
let target = (w as f64 / want).round() as i64;
|
||||||
|
(target - 8..=target + 8)
|
||||||
|
.filter(|lw| *lw >= 1 && (h * lw) % w == 0)
|
||||||
|
.map(|lw| w as f64 / lw as f64)
|
||||||
|
.min_by(|a, b| (a - want).abs().total_cmp(&(b - want).abs()))
|
||||||
|
.unwrap_or(want)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scale of the logical monitor carrying `connector`, if present.
|
||||||
|
fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
||||||
|
state
|
||||||
|
.2
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.5.iter().any(|spec| spec.0 == connector))
|
||||||
|
.map(|l| l.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the virtual output's current scale and, when the user changed it (GNOME Settings
|
||||||
|
/// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it.
|
||||||
|
/// Best-effort: read failures (teardown races, shell restart) are silently skipped.
|
||||||
|
async fn persist_scale_change(dc: &zbus::Proxy<'_>, vconn: &str, scale_key: &str, known: &mut f64) {
|
||||||
|
let Ok(state) = get_state(dc).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(cur) = logical_scale(&state, vconn) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if (cur - *known).abs() > 1e-3 {
|
||||||
|
crate::vdisplay::identity::scales()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.set(scale_key, cur);
|
||||||
|
*known = cur;
|
||||||
|
tracing::info!(
|
||||||
|
scale = cur,
|
||||||
|
"mutter: persisted the client's display scale for the next connect"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,11 +773,11 @@ async fn make_virtual_primary(
|
|||||||
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
||||||
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
||||||
/// vanish). The physical layout is restored on teardown.
|
/// vanish). The physical layout is restored on teardown.
|
||||||
fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
|
fn build_exclusive_config(vconn: &str, vmode: &str, scale: f64) -> Vec<ApplyLogical> {
|
||||||
vec![(
|
vec![(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
1.0,
|
scale,
|
||||||
0,
|
0,
|
||||||
true,
|
true,
|
||||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||||
@@ -599,18 +803,30 @@ fn build_primary_keeping_physicals(
|
|||||||
vconn: &str,
|
vconn: &str,
|
||||||
vmode: &str,
|
vmode: &str,
|
||||||
virt_width: i32,
|
virt_width: i32,
|
||||||
|
scale: f64,
|
||||||
) -> Vec<ApplyLogical> {
|
) -> Vec<ApplyLogical> {
|
||||||
let mut logicals: Vec<ApplyLogical> = vec![(
|
let mut logicals: Vec<ApplyLogical> = vec![(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
1.0,
|
scale,
|
||||||
0,
|
0,
|
||||||
true,
|
true,
|
||||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||||
)];
|
)];
|
||||||
// Append each physical (non-virtual) connector that has a usable mode, to the right of the
|
// Append each physical (non-virtual) connector that has a usable mode, to the right of the
|
||||||
// virtual output, as a non-primary secondary — at its PRE-connect mode (real refresh preserved).
|
// virtual output, as a non-primary secondary — at its PRE-connect mode (real refresh preserved).
|
||||||
let mut x = virt_width.max(0);
|
// Offsets are in the layout's coordinate space: LOGICAL pixels by default on Wayland (the
|
||||||
|
// virtual's footprint is width/scale), physical pixels only under layout-mode 2.
|
||||||
|
let physical_layout = matches!(
|
||||||
|
state.3.get("layout-mode").map(|v| &**v),
|
||||||
|
Some(&Value::U32(2))
|
||||||
|
);
|
||||||
|
let virt_logical_width = if physical_layout {
|
||||||
|
virt_width
|
||||||
|
} else {
|
||||||
|
((virt_width as f64 / scale).round() as i32).max(1)
|
||||||
|
};
|
||||||
|
let mut x = virt_logical_width.max(0);
|
||||||
for mon in &state.1 {
|
for mon in &state.1 {
|
||||||
let conn = &mon.0 .0;
|
let conn = &mon.0 .0;
|
||||||
if conn == vconn {
|
if conn == vconn {
|
||||||
@@ -633,7 +849,7 @@ fn build_primary_keeping_physicals(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::pick_keep_mode;
|
use super::{pick_keep_mode, snap_integral_scale};
|
||||||
|
|
||||||
// (id, w, h, refresh, is_current, is_preferred)
|
// (id, w, h, refresh, is_current, is_preferred)
|
||||||
fn m(
|
fn m(
|
||||||
@@ -692,6 +908,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snap_integral_scale_keeps_valid_scales_and_snaps_odd_ones() {
|
||||||
|
// Already-integral scales survive exactly: 1920/1.5 = 1280, 1080/1.5 = 720.
|
||||||
|
assert_eq!(snap_integral_scale(1.5, 1920, 1080), 1.5);
|
||||||
|
// The GNOME fractional 1.6666… on 3840x2400 (logical 2304x1440) survives.
|
||||||
|
let s = snap_integral_scale(1.666_666_6, 3840, 2400);
|
||||||
|
assert!((s - 3840.0 / 2304.0).abs() < 1e-9, "got {s}");
|
||||||
|
// A scale with no integral logical size nearby snaps to the closest one that has it:
|
||||||
|
// 16:9 logical widths must be multiples of 16 → 1.3 snaps to 1920/1472.
|
||||||
|
let s = snap_integral_scale(1.3, 1920, 1080);
|
||||||
|
assert!((s - 1920.0 / 1472.0).abs() < 1e-9, "got {s}");
|
||||||
|
// Junk input degrades to 1.0.
|
||||||
|
assert_eq!(snap_integral_scale(f64::NAN, 1920, 1080), 1.0);
|
||||||
|
assert_eq!(snap_integral_scale(-2.0, 1920, 1080), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keep_mode_no_pre_uses_state_current_then_preferred() {
|
fn keep_mode_no_pre_uses_state_current_then_preferred() {
|
||||||
// A connector new since the pre-snapshot (no pre mode): is-current wins, else is-preferred.
|
// A connector new since the pre-snapshot (no pre mode): is-current wins, else is-preferred.
|
||||||
|
|||||||
Reference in New Issue
Block a user