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:
@@ -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::*;
|
||||
|
||||
@@ -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-<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 {
|
||||
pub fn new() -> Result<Self> {
|
||||
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<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 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<u32> {
|
||||
/// 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<String> {
|
||||
let ours = format!("Virtual-{VOUT_NAME}");
|
||||
fn other_enabled_outputs(name: &str) -> Vec<String> {
|
||||
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<String> {
|
||||
/// 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<String> {
|
||||
let ours = format!("Virtual-{VOUT_NAME}");
|
||||
fn apply_virtual_primary(name: &str) -> Vec<String> {
|
||||
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<String> {
|
||||
);
|
||||
}
|
||||
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<String> = others
|
||||
.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
|
||||
/// (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<ScreencastStream, ()> for State {
|
||||
fn virtual_output_thread(
|
||||
width: u32,
|
||||
height: u32,
|
||||
name: String,
|
||||
setup_tx: Sender<Result<u32, String>>,
|
||||
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.
|
||||
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<Result<u32, String>>,
|
||||
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)
|
||||
|
||||
@@ -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<Option<Arc<AtomicBool>>>,
|
||||
/// 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<super::identity::DisplayIdentityMap>,
|
||||
// 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<VirtualDisplayManager> = OnceLock::new();
|
||||
@@ -188,7 +188,6 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'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<LUID>` by value (plain `Copy`), so no borrowed
|
||||
|
||||
Reference in New Issue
Block a user