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.
|
//! `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,20 +527,13 @@ 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)
|
)
|
||||||
);
|
.unwrap_or(0);
|
||||||
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);
|
|
||||||
// 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.
|
||||||
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
|
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
|
||||||
|
|||||||
Reference in New Issue
Block a user