Files
punktfunk/crates/punktfunk-host/src/vdisplay/identity.rs
T
enricobuehler c42ce88921 refactor(host/W6.1): extract secret/config-dir helpers into the pf-paths leaf crate
Second de-coupling for the host crate carve (plan §W6.1 leaf). config_dir /
create_private_dir / write_secret_file (+ the Windows DACL helpers) were pub(crate) in the
gamestream junk drawer, yet consumed by vdisplay, stats, gpu, library, mgmt_token,
native_pairing and the Windows service — many of which become pf-media / pf-vdisplay, for
which crate::gamestream would be an illegal upward edge. New leaf crate pf-paths (pure std
+ tracing) owns them; ~40 call sites across 14 files repoint to pf_paths::. gamestream
keeps only its own concerns.

Verified: Linux (home-worker-5) clippy -p pf-paths -p punktfunk-host --all-targets
-D warnings + tests (347 pass, incl. secrets_are_written_owner_only); Windows
(192.168.1.158) clippy --features nvenc,amf-qsv --all-targets green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:07:26 +02:00

386 lines
16 KiB
Rust

//! Platform-neutral **per-client → stable display-id map** (design: `design/display-management.md`
//! §5.4 — identity). A client that reconnects gets the SAME small stable id every time, so the
//! desktop environment can key its per-display config (notably **DPI scaling**) to it and reapply it:
//!
//! * **Windows** seeds the pf-vdisplay monitor's EDID serial + IddCx `ConnectorIndex` from the id, so
//! Windows reapplies the client's saved `PerMonitorSettings` scaling. The id must stay `1..=15`
//! (`ConnectorIndex < MaxMonitorsSupported = 16`).
//! * **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).
//! Anonymous/TOFU/GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream,
//! never reaching this map.
//!
//! Persisted to `<config>/display-identity.json` (migrated from the legacy Windows
//! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts.
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
/// Max stable id. Bounded by the Windows driver's use of the id as the IddCx `ConnectorIndex`
/// (`< MaxMonitorsSupported = 16`), so ids run `1..=15` on every platform for a single shared map.
const MAX_ID: u32 = 15;
/// The map filename (migrated from the legacy Windows-only `pf-vdisplay-identity.json`).
const FILE: &str = "display-identity.json";
const LEGACY_FILE: &str = "pf-vdisplay-identity.json";
/// Compose the map key for a client. `per_client_mode` appends the resolution so a client keeps a
/// distinct id (and thus distinct persisted scaling) per resolution; otherwise the fingerprint alone.
pub(crate) fn identity_key(fp: [u8; 32], mode: (u32, u32), per_client_mode: bool) -> String {
let hex: String = fp.iter().map(|b| format!("{b:02x}")).collect();
if per_client_mode {
format!("{hex}@{}x{}", mode.0, mode.1)
} else {
hex
}
}
#[derive(Serialize, Deserialize, Default)]
struct Store {
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
/// the LRU ordering survives host restarts.
tick: u64,
entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize)]
struct Entry {
/// The composed client key ([`identity_key`]) — the map key. (Serialized as `fp` for
/// back-compat with the legacy Windows `pf-vdisplay-identity.json`.)
#[serde(rename = "fp")]
key: String,
/// The client's stable display id (`1..=15`).
id: u32,
/// MRU stamp (compared against [`Store::tick`]).
seen: u64,
}
/// Persistent client-key → stable-id map (see the module docs).
pub(crate) struct DisplayIdentityMap {
path: PathBuf,
store: Store,
}
impl DisplayIdentityMap {
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
/// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy
/// Windows `pf-vdisplay-identity.json` if the new file is absent.
pub(crate) fn load() -> Self {
let dir = pf_paths::config_dir();
let path = dir.join(FILE);
let bytes = std::fs::read(&path)
.or_else(|_| std::fs::read(dir.join(LEGACY_FILE)))
.ok();
let mut store = bytes
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
.unwrap_or_default();
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s
// found-entry branch returns the stored id verbatim, so an out-of-range id (0 = the "auto"
// sentinel, or > MAX_ID) or a duplicate id/key would flow straight into the display identity.
// Drop out-of-range ids and dedup by BOTH key and id (keeping the most-recently-seen on a
// clash) so no two clients can map to the same id.
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
let mut seen_key = std::collections::HashSet::new();
let mut seen_id = std::collections::HashSet::new();
store.entries.retain(|e| {
(1..=MAX_ID).contains(&e.id) && seen_key.insert(e.key.clone()) && seen_id.insert(e.id)
});
Self { path, store }
}
/// The stable id (`1..=15`) for the client `key` ([`identity_key`]): its remembered id, or a
/// freshly assigned one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
pub(crate) fn resolve(&mut self, key: &str) -> u32 {
self.store.tick = self.store.tick.wrapping_add(1);
let now = self.store.tick;
if let Some(e) = self.store.entries.iter_mut().find(|e| e.key == key) {
e.seen = now;
let id = e.id;
self.persist();
return id;
}
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
let id = (1..=MAX_ID)
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
.unwrap_or_else(|| {
let lru = self
.store
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.seen)
.map(|(i, _)| i)
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
let evicted = self.store.entries.remove(lru);
evicted.id
});
self.store.entries.push(Entry {
key: key.to_string(),
id,
seen: now,
});
self.persist();
id
}
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
fn persist(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) 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 identity map (persisted, loaded once). Shared by the Windows manager and the
/// 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()))
}
/// Resolve the connecting client's stable slot id per the `identity` policy. When no policy is
/// configured, `default` applies — **PerClient on Windows / Shared on Linux**, preserving each
/// platform's historical behavior (Windows always keyed monitors per-client; Linux used one shared
/// output name). `None` ⇒ shared / anonymous → the backend uses its base name / auto slot.
pub(crate) fn resolve_slot(
fp: Option<[u8; 32]>,
mode: (u32, u32),
default: crate::vdisplay::policy::Identity,
) -> Option<u32> {
use crate::vdisplay::policy::Identity;
let id_policy = crate::vdisplay::policy::prefs()
.configured_effective()
.map(|e| e.identity)
.unwrap_or(default);
let per_client_mode = match id_policy {
Identity::Shared => return None,
Identity::PerClient => false,
Identity::PerClientMode => true,
};
let fp = fp?;
Some(
global()
.lock()
.unwrap()
.resolve(&identity_key(fp, mode, per_client_mode)),
)
}
// ---------------------------------------------------------------------------------------
// 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 = pf_paths::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::*;
fn fp(n: u8) -> [u8; 32] {
let mut f = [0u8; 32];
f[0] = n;
f
}
fn temp_map(tag: &str) -> DisplayIdentityMap {
DisplayIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-{tag}-{}.json", std::process::id())),
store: Store::default(),
}
}
#[test]
fn stable_across_calls_and_distinct_per_client() {
let mut m = temp_map("stable");
let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false));
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false));
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false)); // per-client: mode ignored
assert_eq!(a1, a2, "same client → same id (per-client ignores mode)");
assert_ne!(a1, b, "distinct clients → distinct ids");
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn per_client_mode_splits_by_resolution() {
let mut m = temp_map("permode");
let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true));
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true));
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true));
assert_ne!(hd, uhd, "same client, different resolution → different id");
assert_eq!(hd, hd2, "same client + resolution → same id");
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn lru_eviction_reuses_an_id_at_the_cap() {
let mut m = temp_map("lru");
for n in 1..=15u8 {
m.resolve(&identity_key(fp(n), (1920, 1080), false));
}
let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false)); // touch 2 so 1 is LRU
let id16 = m.resolve(&identity_key(fp(16), (1920, 1080), false));
assert!((1..=MAX_ID).contains(&id16));
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn key_composition() {
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);
}
}