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. //! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts.
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize}; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -67,13 +67,19 @@ const VOUT_NAME: &str = "punktfunk";
/// event (deprecated only since v6) for the node id, so cap the bind at 5. /// event (deprecated only since v6) for the node id, so cap the bind at 5.
const MAX_VERSION: u32 = 5; const MAX_VERSION: u32 = 5;
/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up /// The KWin virtual-display driver. Carries the connecting client's cert fingerprint (set before
/// its own Wayland connection/thread that owns the resulting output. /// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME
pub struct KwinDisplay; /// (`Virtual-punktfunk-<id>`) — 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 { impl KwinDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(KwinDisplay) Ok(KwinDisplay::default())
} }
} }
@@ -82,14 +88,32 @@ impl VirtualDisplay for KwinDisplay {
"kwin" "kwin"
} }
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
self.client_fp = fingerprint;
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id →
// `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, 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::<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 (width, height) = (mode.width, mode.height); let (width, height) = (mode.width, mode.height);
let name_thread = name.clone();
thread::Builder::new() thread::Builder::new()
.name("punktfunk-kwin-vout".into()) .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")?; .context("spawn KWin 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)) {
@@ -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 — // 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. // the source runs 60 Hz and the encoder downsamples — so carry the requested rate through.
let achieved_hz = if mode.refresh_hz > 60 { 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 { } else {
mode.refresh_hz mode.refresh_hz
}; };
@@ -118,9 +142,9 @@ impl VirtualDisplay for KwinDisplay {
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
use crate::vdisplay::policy::Topology; use crate::vdisplay::policy::Topology;
let restore = match crate::vdisplay::effective_topology() { let restore = match crate::vdisplay::effective_topology() {
Topology::Exclusive => apply_virtual_primary(), Topology::Exclusive => apply_virtual_primary(&name),
Topology::Primary => { Topology::Primary => {
apply_virtual_primary_only(); apply_virtual_primary_only(&name);
Vec::new() // nothing disabled → nothing to restore Vec::new() // nothing disabled → nothing to restore
} }
Topology::Extend | Topology::Auto => Vec::new(), 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), /// 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 /// 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. /// paces the encoder to the *achieved* rate, not the requested one.
fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 { fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
let output = format!("Virtual-{VOUT_NAME}"); let output = format!("Virtual-{name}");
let mhz = hz.saturating_mul(1000); let mhz = hz.saturating_mul(1000);
let run = |arg: String| { let run = |arg: String| {
std::process::Command::new("kscreen-doctor") std::process::Command::new("kscreen-doctor")
@@ -221,8 +245,8 @@ fn read_active_refresh(output: &str) -> Option<u32> {
/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless /// 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` /// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j`
/// (same source as [`read_active_refresh`]). /// (same source as [`read_active_refresh`]).
fn other_enabled_outputs() -> Vec<String> { fn other_enabled_outputs(name: &str) -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}"); let ours = format!("Virtual-{name}");
let out = match std::process::Command::new("kscreen-doctor") let out = match std::process::Command::new("kscreen-doctor")
.arg("-j") .arg("-j")
.output() .output()
@@ -252,8 +276,8 @@ fn other_enabled_outputs() -> Vec<String> {
/// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the /// 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 /// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
/// showing only the wallpaper) rather than failing the session. /// showing only the wallpaper) rather than failing the session.
fn apply_virtual_primary() -> Vec<String> { fn apply_virtual_primary(name: &str) -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}"); let ours = format!("Virtual-{name}");
let kscreen = |args: &[String]| { let kscreen = |args: &[String]| {
std::process::Command::new("kscreen-doctor") std::process::Command::new("kscreen-doctor")
.args(args) .args(args)
@@ -270,7 +294,7 @@ fn apply_virtual_primary() -> Vec<String> {
); );
} }
std::thread::sleep(Duration::from_millis(200)); std::thread::sleep(Duration::from_millis(200));
let others = other_enabled_outputs(); let others = other_enabled_outputs(name);
if !others.is_empty() { if !others.is_empty() {
let args: Vec<String> = others let args: Vec<String> = others
.iter() .iter()
@@ -285,8 +309,8 @@ fn apply_virtual_primary() -> Vec<String> {
/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled /// **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 /// (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). /// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
fn apply_virtual_primary_only() { fn apply_virtual_primary_only(name: &str) {
let ours = format!("Virtual-{VOUT_NAME}"); let ours = format!("Virtual-{name}");
let ok = std::process::Command::new("kscreen-doctor") let ok = std::process::Command::new("kscreen-doctor")
.arg(format!("output.{ours}.primary")) .arg(format!("output.{ours}.primary"))
.status() .status()
@@ -395,10 +419,11 @@ impl Dispatch<ScreencastStream, ()> for State {
fn virtual_output_thread( fn virtual_output_thread(
width: u32, width: u32,
height: u32, height: u32,
name: String,
setup_tx: Sender<Result<u32, String>>, setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) { ) {
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. // If we never delivered a node id, report the failure to the waiting opener.
let _ = setup_tx.send(Err(format!("{e:#}"))); let _ = setup_tx.send(Err(format!("{e:#}")));
} }
@@ -438,6 +463,7 @@ pub fn is_available() -> bool {
fn run( fn run(
width: u32, width: u32,
height: u32, height: u32,
name: &str,
setup_tx: &Sender<Result<u32, String>>, setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool, stop: &AtomicBool,
) -> Result<()> { ) -> Result<()> {
@@ -460,7 +486,7 @@ fn run(
// Create the virtual output sized to the client, cursor composited into the stream. // Create the virtual output sized to the client, cursor composited into the stream.
let stream = screencast.stream_virtual_output( let stream = screencast.stream_virtual_output(
VOUT_NAME.to_string(), name.to_string(),
width as i32, width as i32,
height as i32, height as i32,
1.0, // scale (logical == physical) 1.0, // scale (logical == physical)
@@ -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 /// 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`). /// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>, idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
/// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the // The per-client stable monitor-id map is now the process-wide `super::identity::global()`
/// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across // (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
/// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`]. // resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
identity_map: Mutex<super::identity::DisplayIdentityMap>, // ConnectorIndex across reconnects and Windows reapplies its saved per-monitor DPI scaling.
} }
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new(); static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
@@ -188,7 +188,6 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
state: Mutex::new(MgrState::Idle), state: Mutex::new(MgrState::Idle),
setup_lock: Mutex::new(()), setup_lock: Mutex::new(()),
idd_session_stop: Mutex::new(None), idd_session_stop: Mutex::new(None),
identity_map: Mutex::new(super::identity::DisplayIdentityMap::load()),
}) })
} }
@@ -528,19 +527,12 @@ impl VirtualDisplayManager {
// Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved // 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 // 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 // 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). // picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior).
let per_client_mode = matches!( let preferred_id = super::identity::resolve_slot(
crate::vdisplay::policy::prefs() client_fp,
.configured_effective() (mode.width, mode.height),
.map(|e| e.identity), crate::vdisplay::policy::Identity::PerClient,
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); .unwrap_or(0);
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // 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. // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.