diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 5d03d8c..c34fb2d 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -779,8 +779,11 @@ pub fn effective_topology() -> policy::Topology { #[cfg(target_os = "linux")] #[path = "vdisplay/linux/gamescope.rs"] mod gamescope; -#[cfg(target_os = "windows")] -#[path = "vdisplay/windows/identity.rs"] +// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID + +// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows +// consumes it in non-test code today — the KWin wiring is the next Stage-3 step. +#[allow(dead_code)] +#[path = "vdisplay/identity.rs"] pub(crate) mod identity; #[cfg(target_os = "linux")] #[path = "vdisplay/linux/kwin.rs"] diff --git a/crates/punktfunk-host/src/vdisplay/identity.rs b/crates/punktfunk-host/src/vdisplay/identity.rs new file mode 100644 index 0000000..7f3d22b --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/identity.rs @@ -0,0 +1,209 @@ +//! 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-`; 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.) +//! +//! 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 `/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 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, +} + +#[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 = crate::gamestream::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::(&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); + } + } +} + +#[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")); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/windows/identity.rs b/crates/punktfunk-host/src/vdisplay/windows/identity.rs deleted file mode 100644 index 24c4201..0000000 --- a/crates/punktfunk-host/src/vdisplay/windows/identity.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Per-client → stable monitor-id map for pf-vdisplay (Phase 2: per-client display-config persistence). -//! -//! Windows keys per-monitor config — notably DPI **scaling** (`HKCU\Control Panel\Desktop\PerMonitorSettings`) -//! — on the monitor's EDID identity AND its OS device path (whose per-connector discriminator is the IddCx -//! `ConnectorIndex` → target UID). The pf-vdisplay driver seeds BOTH the EDID serial and the `ConnectorIndex` -//! from a single monitor `id`. So for Windows to REAPPLY a given client's saved scaling on reconnect, that -//! client must get the SAME `id` every time. This map assigns each client (keyed by its cert fingerprint) a -//! STABLE id and the host passes it as [`AddRequest::preferred_monitor_id`](pf_driver_proto::control::AddRequest). -//! -//! The id space is bounded to `1..=15` because the driver uses the id as the IddCx `ConnectorIndex`, which -//! must stay `< MaxMonitorsSupported` (16). When more than 15 distinct clients are remembered, the -//! LEAST-RECENTLY-USED entry is evicted and its id reused (that evicted client simply re-establishes its -//! scaling once on its next connect). The map persists to `%ProgramData%\punktfunk\pf-vdisplay-identity.json` -//! so ids — and therefore the client→config association — survive host restarts. -//! -//! Anonymous/TOFU and GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream, never -//! reaching this map — they keep the driver's lowest-free slot behavior unchanged. - -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -/// Max stable id. The driver uses the id as the IddCx `ConnectorIndex`, which must stay -/// `< MaxMonitorsSupported` (16) — so ids run `1..=15`. -const MAX_ID: u32 = 15; - -#[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, -} - -#[derive(Serialize, Deserialize)] -struct Entry { - /// Lower-hex client cert fingerprint (the map key). - fp: String, - /// The client's stable monitor id (`1..=15`). - id: u32, - /// MRU stamp (compared against [`Store::tick`]). - seen: u64, -} - -/// Persistent fingerprint → stable-id map (see the module docs). -pub(crate) struct MonitorIdentityMap { - path: PathBuf, - store: Store, -} - -impl MonitorIdentityMap { - /// 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). - pub(crate) fn load() -> Self { - let path = crate::gamestream::config_dir().join("pf-vdisplay-identity.json"); - let mut store = std::fs::read(&path) - .ok() - .and_then(|b| serde_json::from_slice::(&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/fp would flow straight into preferred_monitor_id. Drop out-of-range - // ids and dedup by BOTH fp and id (keeping the most-recently-seen on a clash) so no two fingerprints - // can map to the same id. (The driver also rejects a live-colliding id as a backstop.) - store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen)); - let mut seen_fp = std::collections::HashSet::new(); - let mut seen_id = std::collections::HashSet::new(); - store.entries.retain(|e| { - (1..=MAX_ID).contains(&e.id) && seen_fp.insert(e.fp.clone()) && seen_id.insert(e.id) - }); - Self { path, store } - } - - /// The stable id (`1..=15`) for the client fingerprint `fp`: 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, fp: [u8; 32]) -> u32 { - let key: String = fp.iter().map(|b| format!("{b:02x}")).collect(); - 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.fp == 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 { - fp: key, - 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); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fp(n: u8) -> [u8; 32] { - let mut f = [0u8; 32]; - f[0] = n; - f - } - - #[test] - fn stable_across_calls_and_distinct_per_client() { - let mut m = MonitorIdentityMap { - path: std::env::temp_dir().join(format!("pf-id-test-{}.json", std::process::id())), - store: Store::default(), - }; - let a1 = m.resolve(fp(1)); - let b = m.resolve(fp(2)); - let a2 = m.resolve(fp(1)); - assert_eq!(a1, a2, "same client → same id"); - 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 lru_eviction_reuses_an_id_at_the_cap() { - let mut m = MonitorIdentityMap { - path: std::env::temp_dir().join(format!("pf-id-lru-{}.json", std::process::id())), - store: Store::default(), - }; - // Fill all 15 ids (clients 1..=15), then touch client 2 so client 1 is the LRU. - for n in 1..=15u8 { - m.resolve(fp(n)); - } - let _ = m.resolve(fp(2)); - // A 16th client evicts the LRU (client 1) and reuses its id; ids stay bounded. - let id16 = m.resolve(fp(16)); - 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); - } -} diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index e6daa28..fe1c168 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -172,7 +172,7 @@ pub(crate) struct VirtualDisplayManager { /// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the /// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across /// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`]. - identity_map: Mutex, + identity_map: Mutex, } static VDM: OnceLock = OnceLock::new(); @@ -188,7 +188,7 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa state: Mutex::new(MgrState::Idle), setup_lock: Mutex::new(()), idd_session_stop: Mutex::new(None), - identity_map: Mutex::new(super::identity::MonitorIdentityMap::load()), + identity_map: Mutex::new(super::identity::DisplayIdentityMap::load()), }) } @@ -527,9 +527,20 @@ impl VirtualDisplayManager { ) -> Result { // Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved // per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver - // auto-allocates the lowest-free id (the original slot-based behavior). + // auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy + // picks the key: per-client (fingerprint) or per-client-mode (fingerprint + resolution). + let per_client_mode = matches!( + crate::vdisplay::policy::prefs() + .configured_effective() + .map(|e| e.identity), + Some(crate::vdisplay::policy::Identity::PerClientMode) + ); let preferred_id = client_fp - .map(|fp| self.identity_map.lock().unwrap().resolve(fp)) + .map(|fp| { + let key = + super::identity::identity_key(fp, (mode.width, mode.height), per_client_mode); + self.identity_map.lock().unwrap().resolve(&key) + }) .unwrap_or(0); // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.