feat(vdisplay): KWin per-slot output naming for persistent scaling (Stage 3)

The KWin backend names its output Virtual-punktfunk-<id> from the client's
stable identity slot, so KWin persists per-output config (scale/mode) by name in
kwinoutputconfig.json and reapplies that client's scaling on reconnect — the KDE
scaling ask. Also fixes the latent clash where two concurrent sessions both used
Virtual-punktfunk (topology name-matching now uses the per-slot name).

- identity::global() + resolve_slot(fp, mode, default) — the shared persisted map
  (Windows manager dropped its own field; both use the global — never same-process).
  Default identity is per-platform: PerClient on Windows, Shared on Linux, so
  unconfigured hosts keep today's behavior (Linux = single 'punktfunk' name).
- KwinDisplay carries the client fp (set_client_identity), computes the per-slot
  name, threads it through the stream_virtual_output name + the topology helpers
  (set_custom_refresh / apply_virtual_primary[_only] / other_enabled_outputs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 08:54:39 +00:00
parent b150d79626
commit d73951414c
3 changed files with 93 additions and 38 deletions
@@ -18,6 +18,7 @@
//! `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};
@@ -147,6 +148,42 @@ 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`.
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)),
)
}
#[cfg(test)]
mod tests {
use super::*;