From d73951414ce3497a2ac981680f0b487a81f4ea6c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 08:54:39 +0000 Subject: [PATCH] feat(vdisplay): KWin per-slot output naming for persistent scaling (Stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KWin backend names its output Virtual-punktfunk- 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) --- .../punktfunk-host/src/vdisplay/identity.rs | 37 +++++++++++ .../punktfunk-host/src/vdisplay/linux/kwin.rs | 64 +++++++++++++------ .../src/vdisplay/windows/manager.rs | 30 ++++----- 3 files changed, 93 insertions(+), 38 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/identity.rs b/crates/punktfunk-host/src/vdisplay/identity.rs index 7f3d22b..83de969 100644 --- a/crates/punktfunk-host/src/vdisplay/identity.rs +++ b/crates/punktfunk-host/src/vdisplay/identity.rs @@ -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 { + static MAP: OnceLock> = 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 { + 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::*; diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index efad859..a9fb20a 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -67,13 +67,19 @@ const VOUT_NAME: &str = "punktfunk"; /// event (deprecated only since v6) for the node id, so cap the bind at 5. const MAX_VERSION: u32 = 5; -/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up -/// its own Wayland connection/thread that owns the resulting output. -pub struct KwinDisplay; +/// The KWin virtual-display driver. Carries the connecting client's cert fingerprint (set before +/// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME +/// (`Virtual-punktfunk-`) — KWin persists per-output config (scale/mode) keyed by name in +/// `kwinoutputconfig.json`, so a stable name makes KDE reapply that client's scaling on reconnect +/// (Stage 3). Each `create` spins up its own Wayland connection/thread that owns the output. +#[derive(Default)] +pub struct KwinDisplay { + client_fp: Option<[u8; 32]>, +} impl KwinDisplay { pub fn new() -> Result { - Ok(KwinDisplay) + Ok(KwinDisplay::default()) } } @@ -82,14 +88,32 @@ impl VirtualDisplay for KwinDisplay { "kwin" } + fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) { + self.client_fp = fingerprint; + } + fn create(&mut self, mode: Mode) -> Result { + // Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id → + // `punktfunk-` (KWin exposes `Virtual-punktfunk-`, whose per-output config KWin + // persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux + // defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND + // it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`. + let name = match crate::vdisplay::identity::resolve_slot( + self.client_fp, + (mode.width, mode.height), + crate::vdisplay::policy::Identity::Shared, + ) { + Some(id) => format!("{VOUT_NAME}-{id}"), + None => VOUT_NAME.to_string(), + }; let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); let (width, height) = (mode.width, mode.height); + let name_thread = name.clone(); thread::Builder::new() .name("punktfunk-kwin-vout".into()) - .spawn(move || virtual_output_thread(width, height, setup_tx, stop_thread)) + .spawn(move || virtual_output_thread(width, height, name_thread, setup_tx, stop_thread)) .context("spawn KWin virtual-output thread")?; let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { @@ -107,7 +131,7 @@ impl VirtualDisplay for KwinDisplay { // rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install — // the source runs 60 Hz and the encoder downsamples — so carry the requested rate through. let achieved_hz = if mode.refresh_hz > 60 { - set_custom_refresh(width, height, mode.refresh_hz) + set_custom_refresh(width, height, mode.refresh_hz, &name) } else { mode.refresh_hz }; @@ -118,9 +142,9 @@ impl VirtualDisplay for KwinDisplay { // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). use crate::vdisplay::policy::Topology; let restore = match crate::vdisplay::effective_topology() { - Topology::Exclusive => apply_virtual_primary(), + Topology::Exclusive => apply_virtual_primary(&name), Topology::Primary => { - apply_virtual_primary_only(); + apply_virtual_primary_only(&name); Vec::new() // nothing disabled → nothing to restore } Topology::Extend | Topology::Auto => Vec::new(), @@ -140,8 +164,8 @@ impl VirtualDisplay for KwinDisplay { /// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected), /// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller /// paces the encoder to the *achieved* rate, not the requested one. -fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 { - let output = format!("Virtual-{VOUT_NAME}"); +fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 { + let output = format!("Virtual-{name}"); let mhz = hz.saturating_mul(1000); let run = |arg: String| { std::process::Command::new("kscreen-doctor") @@ -221,8 +245,8 @@ fn read_active_refresh(output: &str) -> Option { /// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless /// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j` /// (same source as [`read_active_refresh`]). -fn other_enabled_outputs() -> Vec { - let ours = format!("Virtual-{VOUT_NAME}"); +fn other_enabled_outputs(name: &str) -> Vec { + let ours = format!("Virtual-{name}"); let out = match std::process::Command::new("kscreen-doctor") .arg("-j") .output() @@ -252,8 +276,8 @@ fn other_enabled_outputs() -> Vec { /// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the /// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly /// showing only the wallpaper) rather than failing the session. -fn apply_virtual_primary() -> Vec { - let ours = format!("Virtual-{VOUT_NAME}"); +fn apply_virtual_primary(name: &str) -> Vec { + let ours = format!("Virtual-{name}"); let kscreen = |args: &[String]| { std::process::Command::new("kscreen-doctor") .args(args) @@ -270,7 +294,7 @@ fn apply_virtual_primary() -> Vec { ); } std::thread::sleep(Duration::from_millis(200)); - let others = other_enabled_outputs(); + let others = other_enabled_outputs(name); if !others.is_empty() { let args: Vec = others .iter() @@ -285,8 +309,8 @@ fn apply_virtual_primary() -> Vec { /// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled /// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a /// physical screen stays usable. Nothing to restore on teardown (we disabled nothing). -fn apply_virtual_primary_only() { - let ours = format!("Virtual-{VOUT_NAME}"); +fn apply_virtual_primary_only(name: &str) { + let ours = format!("Virtual-{name}"); let ok = std::process::Command::new("kscreen-doctor") .arg(format!("output.{ours}.primary")) .status() @@ -395,10 +419,11 @@ impl Dispatch for State { fn virtual_output_thread( width: u32, height: u32, + name: String, setup_tx: Sender>, stop: Arc, ) { - if let Err(e) = run(width, height, &setup_tx, &stop) { + if let Err(e) = run(width, height, &name, &setup_tx, &stop) { // If we never delivered a node id, report the failure to the waiting opener. let _ = setup_tx.send(Err(format!("{e:#}"))); } @@ -438,6 +463,7 @@ pub fn is_available() -> bool { fn run( width: u32, height: u32, + name: &str, setup_tx: &Sender>, stop: &AtomicBool, ) -> Result<()> { @@ -460,7 +486,7 @@ fn run( // Create the virtual output sized to the client, cursor composited into the stream. let stream = screencast.stream_virtual_output( - VOUT_NAME.to_string(), + name.to_string(), width as i32, height as i32, 1.0, // scale (logical == physical) diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index fe1c168..23e6198 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -169,10 +169,10 @@ pub(crate) struct VirtualDisplayManager { /// The current IDD-push session's stop flag; a new connection signals the prior one to release its /// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`). idd_session_stop: Mutex>>, - /// 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, + // The per-client stable monitor-id map is now the process-wide `super::identity::global()` + // (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE + // resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx + // ConnectorIndex across reconnects and Windows reapplies its saved per-monitor DPI scaling. } static VDM: OnceLock = OnceLock::new(); @@ -188,7 +188,6 @@ 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::DisplayIdentityMap::load()), }) } @@ -528,20 +527,13 @@ impl VirtualDisplayManager { // 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). 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| { - let key = - super::identity::identity_key(fp, (mode.width, mode.height), per_client_mode); - self.identity_map.lock().unwrap().resolve(&key) - }) - .unwrap_or(0); + // picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior). + let preferred_id = super::identity::resolve_slot( + client_fp, + (mode.width, mode.height), + crate::vdisplay::policy::Identity::PerClient, + ) + .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. // `resolve_render_pin()` returns an `Option` by value (plain `Copy`), so no borrowed