refactor(host/W6.2): extract virtual-display orchestration into the pf-vdisplay crate
vdisplay.rs + vdisplay/* (the per-compositor Linux backends — KWin zkde-screencast,
wlroots swaymsg, Mutter RemoteDesktop, Hyprland — and the Windows IddCx/pf-vdisplay
driver backend, behind one VirtualDisplay trait; the mode-conflict admission
registry, the display policy/identity/custom-preset state, and the session-env /
gamescope routing) move into crates/pf-vdisplay (plan §W6). The DDC/CI panel-power
control (used only here) and the KWin zkde protocol XML move with it. This
completes the host-crate decomposition: capture, encode, inject, and vdisplay are
now four subsystem crates over the shared leaves, and punktfunk-host is the
orchestrator (serve/supervisor + native + gamestream + mgmt).
Coupling breaks (all down-only, cargo-tree acyclic):
- capture::dxgi identity -> pf_frame::dxgi; win_display/monitor_devnode/
console_session_mismatch -> pf-win-display leaf; can_open_another_session ->
pf-encode (the NVENC session-budget admission gate — acyclic peer edge).
- The registry's DisplayCreated/DisplayReleased emits into the host SSE event bus
invert to a leaf hook: pf-vdisplay emits a neutral DisplayEvent to a
host-registered DISPLAY_EVENT_SINK, so it never reaches the orchestrator's
events module.
- The IddCx driver module is renamed pf_vdisplay -> driver (its old name collided
with the crate name through the host's `mod vdisplay` shim glob).
The host keeps `mod vdisplay { pub use pf_vdisplay::* }` so every crate::vdisplay::*
path (serve/mgmt/native/the capture FrameChannelSender seam) is unchanged; the
heavy deps (wayland/ashpd/tokio + the zkde protocol) moved with the crate.
Co-authored: a fail-closed IOCTL-reply-length security fix (reject short/zeroed
pf-vdisplay driver replies before trusting protocol_version/target_id/wudf_pid/luid,
security-review 2026-07-17) rides this commit in the moved driver module.
Verified: Linux clippy -D warnings (pf-vdisplay + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-vdisplay 63/63 + host 167/167 tests; Windows clippy -D warnings
(pf-vdisplay --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,7 +118,7 @@ pub fn capture_virtual_output(
|
||||
// closed for the process lifetime, so reconstructing the `HANDLE` and issuing the
|
||||
// `IOCTL_SET_FRAME_CHANNEL` is sound (`send_frame_channel`'s precondition).
|
||||
unsafe {
|
||||
crate::vdisplay::pf_vdisplay::send_frame_channel(
|
||||
crate::vdisplay::driver::send_frame_channel(
|
||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
||||
req,
|
||||
)
|
||||
|
||||
@@ -29,9 +29,6 @@ mod wol;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/crash.rs"]
|
||||
mod crash;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/ddc.rs"]
|
||||
mod ddc;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/drm_sync.rs"]
|
||||
mod drm_sync;
|
||||
@@ -76,13 +73,16 @@ mod session_status;
|
||||
mod spike;
|
||||
mod stats_recorder;
|
||||
mod stream_marker;
|
||||
mod vdisplay;
|
||||
// The Windows display-topology cluster (CCD/GDI mode-set + PnP monitor devnodes) lives in the
|
||||
// `pf-win-display` leaf crate (plan §W6); import the modules at the crate root so the host's
|
||||
// `crate::{win_display,monitor_devnode}::*` paths stay valid. (`display_events` moved with the
|
||||
// IDD-push capturer into pf-capture, which names it directly.)
|
||||
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
|
||||
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
|
||||
#[cfg(target_os = "windows")]
|
||||
use pf_win_display::{monitor_devnode, win_display};
|
||||
use pf_win_display::monitor_devnode;
|
||||
// Virtual-display orchestration lives in the `pf-vdisplay` subsystem crate (plan §W6); this shim
|
||||
// keeps every existing `crate::vdisplay::*` path valid (serve/mgmt/native/capture consume the trait,
|
||||
// registry, and manager through it). The DDC panel control + the KWin zkde protocol moved with it.
|
||||
mod vdisplay {
|
||||
pub(crate) use pf_vdisplay::*;
|
||||
}
|
||||
// The zero-copy GPU plumbing lives in the `pf-zerocopy` leaf crate (plan §W6); this shim keeps
|
||||
// every existing `crate::zerocopy::*` path valid for the host's remaining callers (session_plan).
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -183,6 +183,23 @@ fn real_main() -> Result<()> {
|
||||
punktfunk_core::ABI_VERSION
|
||||
);
|
||||
|
||||
// Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a
|
||||
// neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set.
|
||||
let _ = pf_vdisplay::DISPLAY_EVENT_SINK.set(Box::new(|ev| match ev {
|
||||
pf_vdisplay::DisplayEvent::Created {
|
||||
backend,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
} => events::emit(events::EventKind::DisplayCreated {
|
||||
backend,
|
||||
mode: events::mode_str(width, height, refresh_hz),
|
||||
}),
|
||||
pf_vdisplay::DisplayEvent::Released { count } => {
|
||||
events::emit(events::EventKind::DisplayReleased { count })
|
||||
}
|
||||
}));
|
||||
|
||||
// Install the win32u GPU-preference hook (same technique as Apollo, reimplemented — no GPL source
|
||||
// copied) BEFORE anything touches DXGI (the virtual-display
|
||||
// render-adapter selection creates a DXGI factory during virtual-display setup, well before
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
//! Virtual display orchestration (plan §6) — the project's differentiator.
|
||||
//!
|
||||
//! A [`VirtualDisplay`] creates a *client-sized* output on demand, rendered natively and
|
||||
//! headless (no scaling), to be captured and streamed, then torn down on disconnect. There is
|
||||
//! no cross-compositor Wayland protocol for this, so each compositor has its own backend behind
|
||||
//! this trait:
|
||||
//!
|
||||
//! * **KWin** — privileged `zkde_screencast_unstable_v1::stream_virtual_output` ([`kwin`]).
|
||||
//! * **wlroots/Sway** — `swaymsg create_output` + `output mode --custom` ([`wlroots`]).
|
||||
//! * **Mutter/GNOME** — D-Bus `RemoteDesktop` + `ScreenCast.RecordVirtual` ([`mutter`]).
|
||||
//!
|
||||
//! [`VirtualDisplay::create`] returns a [`VirtualOutput`]: the PipeWire node to capture plus an
|
||||
//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture
|
||||
//! consumes the node via [`crate::capture::capture_virtual_output`].
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
pub use punktfunk_core::Mode;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::fd::OwnedFd;
|
||||
|
||||
/// The virtual-display backend contract — [`DisplayOwnership`], [`VirtualOutput`], and the
|
||||
/// [`VirtualDisplay`] trait (plan §W3). Re-exported so `crate::vdisplay::VirtualDisplay` etc. stay
|
||||
/// stable for the ~30 external call sites.
|
||||
#[path = "vdisplay/backend.rs"]
|
||||
pub(crate) mod backend;
|
||||
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
|
||||
|
||||
/// Live-session detection + session-epoch + env retargeting (plan §W3).
|
||||
#[path = "vdisplay/session.rs"]
|
||||
pub(crate) mod session;
|
||||
pub use session::{
|
||||
apply_session_env, compositor_for_kind, detect_active_session, observe_session_instance,
|
||||
settle_desktop_portal, ActiveKind, ActiveSession, SessionEnv,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use session::{session_epoch, try_recover_session};
|
||||
|
||||
/// Gamescope-session routing (plan §W3).
|
||||
#[path = "vdisplay/routing.rs"]
|
||||
pub(crate) mod routing;
|
||||
pub use routing::{
|
||||
apply_input_env, restore_managed_session, restore_takeover_on_startup, start_restore_worker,
|
||||
wants_dedicated_game_session,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use routing::{
|
||||
cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session,
|
||||
launch_is_nested,
|
||||
};
|
||||
|
||||
/// Compositors punktfunk knows how to drive (plan §6).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Compositor {
|
||||
/// KWin / Plasma 6 — `zkde_screencast` virtual output.
|
||||
Kwin,
|
||||
/// wlroots proper (Sway / River) — headless `swaymsg create_output`.
|
||||
Wlroots,
|
||||
/// Mutter / GNOME — headless backend + Mutter DBus `RecordVirtual`.
|
||||
Mutter,
|
||||
/// gamescope — spawned headless at the client's size/refresh; capture its PipeWire node.
|
||||
Gamescope,
|
||||
/// Hyprland — headless `hyprctl output create` + the xdg-desktop-portal-hyprland (xdph)
|
||||
/// ScreenCast portal. A distinct backend from [`Wlroots`](Compositor::Wlroots): Hyprland
|
||||
/// dropped wlroots in v0.42 but kept the client-facing wlr protocols, so it shares the wlr
|
||||
/// virtual-input path yet needs its own IPC (`hyprctl`) and portal (xdph) — see
|
||||
/// `design/hyprland-support.md`.
|
||||
Hyprland,
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Stable lowercase id used on the wire / management API (matches
|
||||
/// [`punktfunk_core::CompositorPref::as_str`]).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Compositor::Kwin => "kwin",
|
||||
Compositor::Wlroots => "wlroots",
|
||||
Compositor::Mutter => "mutter",
|
||||
Compositor::Gamescope => "gamescope",
|
||||
Compositor::Hyprland => "hyprland",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human label for UIs.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Compositor::Kwin => "KWin / KDE Plasma",
|
||||
Compositor::Wlroots => "wlroots (Sway / River)",
|
||||
Compositor::Mutter => "Mutter / GNOME",
|
||||
Compositor::Gamescope => "gamescope",
|
||||
Compositor::Hyprland => "Hyprland",
|
||||
}
|
||||
}
|
||||
|
||||
/// The protocol [`punktfunk_core::CompositorPref`] naming this backend.
|
||||
pub fn as_pref(self) -> punktfunk_core::CompositorPref {
|
||||
use punktfunk_core::CompositorPref as P;
|
||||
match self {
|
||||
Compositor::Kwin => P::Kwin,
|
||||
Compositor::Wlroots => P::Wlroots,
|
||||
Compositor::Mutter => P::Mutter,
|
||||
Compositor::Gamescope => P::Gamescope,
|
||||
// D2: no distinct wire byte for Hyprland — it shares the wlroots-family `Wlroots` pref.
|
||||
// A client asking for `wlroots`/`hyprland` gets whichever of the two is the live session
|
||||
// ([`pick_compositor`](crate::native::pick_compositor) resolves the family).
|
||||
Compositor::Hyprland => P::Wlroots,
|
||||
}
|
||||
}
|
||||
|
||||
/// The concrete backend a [`punktfunk_core::CompositorPref`] names, or `None` for `Auto`.
|
||||
pub fn from_pref(p: punktfunk_core::CompositorPref) -> Option<Compositor> {
|
||||
use punktfunk_core::CompositorPref as P;
|
||||
Some(match p {
|
||||
P::Auto => return None,
|
||||
P::Kwin => Compositor::Kwin,
|
||||
P::Wlroots => Compositor::Wlroots,
|
||||
P::Mutter => Compositor::Mutter,
|
||||
P::Gamescope => Compositor::Gamescope,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every backend, in a stable display order (for enumeration / UIs).
|
||||
pub fn all() -> [Compositor; 5] {
|
||||
[
|
||||
Compositor::Kwin,
|
||||
Compositor::Gamescope,
|
||||
Compositor::Mutter,
|
||||
Compositor::Wlroots,
|
||||
Compositor::Hyprland,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The compositor backends usable on this host *right now*: gamescope wherever its binary is
|
||||
/// installed (it spawns a nested session — independent of the running desktop), plus the live
|
||||
/// session's own compositor (KWin / Mutter / wlroots) when the host runs inside it. Cheap,
|
||||
/// side-effect-free probes — safe to call per management request. A concrete client preference
|
||||
/// is validated against this set before it's honored (see the punktfunk/1 handshake's resolution).
|
||||
pub fn available() -> Vec<Compositor> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
if kwin::is_available() {
|
||||
v.push(Compositor::Kwin);
|
||||
}
|
||||
if gamescope::is_available() {
|
||||
v.push(Compositor::Gamescope);
|
||||
}
|
||||
if mutter::is_available() {
|
||||
v.push(Compositor::Mutter);
|
||||
}
|
||||
if wlroots::is_available() {
|
||||
v.push(Compositor::Wlroots);
|
||||
}
|
||||
if hyprland::is_available() {
|
||||
v.push(Compositor::Hyprland);
|
||||
}
|
||||
v
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
|
||||
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
|
||||
/// the default concurrent native sessions each running `resolve_compositor` in its own
|
||||
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
|
||||
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
|
||||
/// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is
|
||||
/// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never
|
||||
/// rides the process env at all — the remaining knobs here (session retarget, gamescope sub-mode)
|
||||
/// still carry a cross-session *value* confusion window inherent to a process-global env.
|
||||
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
|
||||
pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f()
|
||||
}
|
||||
|
||||
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||
pub fn detect() -> Result<Compositor> {
|
||||
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
||||
return match v.trim().to_ascii_lowercase().as_str() {
|
||||
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
|
||||
// `hyprland` names the distinct backend (D1); `wlroots`/`sway`/`wlr` stay wlroots-proper.
|
||||
"hyprland" | "hypr" => Ok(Compositor::Hyprland),
|
||||
"wlroots" | "sway" | "wlr" | "river" => Ok(Compositor::Wlroots),
|
||||
"mutter" | "gnome" => Ok(Compositor::Mutter),
|
||||
"gamescope" => Ok(Compositor::Gamescope),
|
||||
other => {
|
||||
anyhow::bail!(
|
||||
"unknown PUNKTFUNK_COMPOSITOR '{other}' (kwin|wlroots|hyprland|mutter|gamescope)"
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
||||
return Ok(c);
|
||||
}
|
||||
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
if desktop.contains("KDE") {
|
||||
Ok(Compositor::Kwin)
|
||||
} else if desktop.contains("GNOME") {
|
||||
Ok(Compositor::Mutter)
|
||||
} else if desktop.contains("HYPRLAND") {
|
||||
Ok(Compositor::Hyprland)
|
||||
} else if desktop.contains("SWAY") || desktop.contains("WLROOTS") {
|
||||
Ok(Compositor::Wlroots)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"could not detect compositor: no live graphical session for this uid and \
|
||||
XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the virtual-display driver for `compositor`.
|
||||
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match compositor {
|
||||
Compositor::Kwin => Ok(Box::new(kwin::KwinDisplay::new()?)),
|
||||
Compositor::Gamescope => Ok(Box::new(gamescope::GamescopeDisplay::new()?)),
|
||||
Compositor::Mutter => Ok(Box::new(mutter::MutterDisplay::new()?)),
|
||||
Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)),
|
||||
Compositor::Hyprland => Ok(Box::new(hyprland::HyprlandDisplay::new()?)),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
|
||||
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
|
||||
let _ = compositor;
|
||||
// `ensure_available` self-heals the hostless-zombie state a WUDFHost crash leaves (adapter
|
||||
// devnode present, interface gone): one device cycle + re-probe before giving up.
|
||||
anyhow::ensure!(
|
||||
pf_vdisplay::ensure_available(),
|
||||
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
|
||||
not loaded (the host installer bundles it; reinstall or check the driver state)"
|
||||
);
|
||||
Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = compositor;
|
||||
anyhow::bail!("virtual displays require Linux or Windows")
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right
|
||||
/// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate
|
||||
/// on actual readiness instead of racing the compositor with a blind sleep.
|
||||
///
|
||||
/// KWin gets a real check (the privileged `zkde_screencast` global must be advertised). The
|
||||
/// others are spawn/D-Bus/portal-based and have no equivalent pre-flight global, so a probe
|
||||
/// just confirms the backend opens — `Ok(())` means "go ahead and try `create`".
|
||||
pub fn probe(compositor: Compositor) -> Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match compositor {
|
||||
Compositor::Kwin => kwin::probe(),
|
||||
// Hyprland gets a real pre-flight: `hyprctl` must reach the compositor (else a clear
|
||||
// error instead of a create-time failure), plus the permission-system warning.
|
||||
Compositor::Hyprland => hyprland::probe(),
|
||||
// gamescope spawns its own nested session per `create`; Mutter is D-Bus on demand;
|
||||
// wlroots creates the output on demand — nothing to pre-check beyond "Linux".
|
||||
Compositor::Gamescope | Compositor::Mutter | Compositor::Wlroots => Ok(()),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = compositor;
|
||||
pf_vdisplay::probe()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
let _ = compositor;
|
||||
anyhow::bail!("virtual displays require Linux or Windows")
|
||||
}
|
||||
}
|
||||
|
||||
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
|
||||
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
|
||||
// it), so no cfg gate. See `design/display-management.md`.
|
||||
#[path = "vdisplay/policy.rs"]
|
||||
pub(crate) mod policy;
|
||||
|
||||
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
|
||||
// property-tested; the registry executes the side effects its transitions dictate.
|
||||
#[path = "vdisplay/lifecycle.rs"]
|
||||
pub(crate) mod lifecycle;
|
||||
|
||||
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
|
||||
// later), for the management API's /display/state + /display/release.
|
||||
#[path = "vdisplay/registry.rs"]
|
||||
pub(crate) mod registry;
|
||||
|
||||
// The pure display-arrangement engine (auto-row / manual → per-member positions), platform-neutral
|
||||
// and unit-tested; the registry (state readout) and the KWin position apply consume it.
|
||||
#[path = "vdisplay/layout.rs"]
|
||||
pub(crate) mod layout;
|
||||
|
||||
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
|
||||
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
|
||||
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
|
||||
/// Linux desktop path, where "stream this desktop" means promoting the virtual output to sole).
|
||||
pub fn resolve_topology(t: policy::Topology) -> policy::Topology {
|
||||
match t {
|
||||
policy::Topology::Auto => {
|
||||
if pf_host_config::config().compositor.is_some() {
|
||||
policy::Topology::Extend
|
||||
} else {
|
||||
policy::Topology::Exclusive
|
||||
}
|
||||
}
|
||||
concrete => concrete,
|
||||
}
|
||||
}
|
||||
|
||||
/// The concrete display topology for the current session — what the per-compositor backends (and the
|
||||
/// Windows isolate gate) apply at create time. Precedence, mirroring the rest of the policy surface:
|
||||
/// the **console policy** when configured, else the legacy **`PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`**
|
||||
/// env (an operator's explicit choice — `1`→exclusive, `0`→extend), else the **Auto** default
|
||||
/// ([`resolve_topology`]: exclusive on the auto-detected desktop / Windows, extend under a compositor
|
||||
/// pin). Always resolved (never [`policy::Topology::Auto`]). This is the Stage-2 replacement for the
|
||||
/// `apply_session_env` boolean write — the backends read policy directly, so the `primary` level
|
||||
/// (distinct from `exclusive`) becomes expressible and one process-env mutation drops off the connect
|
||||
/// path.
|
||||
pub fn effective_topology() -> policy::Topology {
|
||||
if let Some(e) = policy::prefs().configured_effective() {
|
||||
return resolve_topology(e.topology);
|
||||
}
|
||||
// Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at
|
||||
// most one of these is set), else the Auto default.
|
||||
let legacy = [
|
||||
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
|
||||
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|k| std::env::var(k).ok());
|
||||
match legacy.as_deref().map(str::trim) {
|
||||
Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive,
|
||||
Some("0" | "false" | "no" | "off") => policy::Topology::Extend,
|
||||
_ => resolve_topology(policy::Topology::Auto),
|
||||
}
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
||||
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/gamescope.rs"]
|
||||
mod gamescope;
|
||||
|
||||
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
|
||||
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
|
||||
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
|
||||
#[allow(dead_code)]
|
||||
#[path = "vdisplay/identity.rs"]
|
||||
pub(crate) mod identity;
|
||||
|
||||
// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the
|
||||
// live-session registry, wired into the punktfunk/1 handshake.
|
||||
#[path = "vdisplay/admission.rs"]
|
||||
pub(crate) mod admission;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/hyprland.rs"]
|
||||
mod hyprland;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin.rs"]
|
||||
mod kwin;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub(crate) mod manager;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/mutter.rs"]
|
||||
mod mutter;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/pf_vdisplay.rs"]
|
||||
pub(crate) mod pf_vdisplay;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/wlroots.rs"]
|
||||
mod wlroots;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn active_kind_maps_to_its_backend() {
|
||||
assert_eq!(
|
||||
compositor_for_kind(ActiveKind::Gaming),
|
||||
Some(Compositor::Gamescope)
|
||||
);
|
||||
assert_eq!(
|
||||
compositor_for_kind(ActiveKind::DesktopKde),
|
||||
Some(Compositor::Kwin)
|
||||
);
|
||||
assert_eq!(
|
||||
compositor_for_kind(ActiveKind::DesktopGnome),
|
||||
Some(Compositor::Mutter)
|
||||
);
|
||||
assert_eq!(
|
||||
compositor_for_kind(ActiveKind::DesktopWlroots),
|
||||
Some(Compositor::Wlroots)
|
||||
);
|
||||
assert_eq!(
|
||||
compositor_for_kind(ActiveKind::DesktopHyprland),
|
||||
Some(Compositor::Hyprland)
|
||||
);
|
||||
// No live session → no backend; the caller turns this into a handshake error / fallback.
|
||||
assert_eq!(compositor_for_kind(ActiveKind::None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_active_session_is_side_effect_free_and_terminates() {
|
||||
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
|
||||
// any box (CI has no graphical session → ActiveKind::None, with the runtime-dir anchor).
|
||||
let a = detect_active_session();
|
||||
// The runtime-dir anchor is a Linux (XDG) concept; Windows has no equivalent.
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(!a.env.xdg_runtime_dir.is_empty());
|
||||
// Wayland sockets are only resolved for the Wayland-protocol desktops.
|
||||
if matches!(
|
||||
a.kind,
|
||||
ActiveKind::Gaming | ActiveKind::DesktopGnome | ActiveKind::None
|
||||
) {
|
||||
assert!(a.env.wayland_display.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
//! Mode-conflict **admission** (design: `design/display-management.md` §5.3, Stage 4). When a
|
||||
//! *different* client connects while another client's session is already live, the `mode_conflict`
|
||||
//! policy decides what happens — BEFORE the Welcome / RTSP launch, so the client gets an honest answer
|
||||
//! instead of a mid-build failure:
|
||||
//!
|
||||
//! * `separate` — proceed on a fresh display at the requested mode (today's Linux multi-view / the
|
||||
//! default; no behavior change unconfigured).
|
||||
//! * `join` — admit at the live display's mode (honest-downgrade: the Welcome carries the real mode).
|
||||
//! * `steal` — signal the victim session(s)' stop flag(s), wait the release grace, then serve.
|
||||
//! * `reject` — refuse with a typed handshake error naming the live mode + client.
|
||||
//!
|
||||
//! A **live-session registry** ([`register`]) lets the decision see the current sessions (identity +
|
||||
//! mode + stop flag); each session registers once admitted and drops its [`LiveGuard`] on end. The
|
||||
//! decision itself ([`decide`]) is pure over a session slice, so it is unit-tested exhaustively.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use crate::vdisplay::policy::{self, ModeConflict};
|
||||
|
||||
/// A currently-live session, as admission sees it.
|
||||
#[derive(Clone)]
|
||||
pub struct LiveSession {
|
||||
id: u64,
|
||||
/// The owning client's cert fingerprint (`None` = anonymous / no client cert presented).
|
||||
pub identity: Option<[u8; 32]>,
|
||||
pub mode: (u32, u32, u32),
|
||||
/// The session's stop flag — signaled to preempt it on `steal`.
|
||||
pub stop: Arc<AtomicBool>,
|
||||
/// Short client label for `reject` messages.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// The admission outcome for a connecting session.
|
||||
#[derive(Debug)]
|
||||
pub enum Admission {
|
||||
/// No conflict / `separate`: proceed on a fresh display at the requested mode.
|
||||
Separate,
|
||||
/// `join`: admit at this (live) mode — share the existing display (honest-downgrade).
|
||||
Join((u32, u32, u32)),
|
||||
/// `steal`: signal these victim stop flags, wait the release grace, then proceed at the requested mode.
|
||||
Steal(Vec<Arc<AtomicBool>>),
|
||||
/// `reject`: refuse with this reason (host-busy + live mode + client label).
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn table() -> &'static Mutex<Vec<LiveSession>> {
|
||||
static T: OnceLock<Mutex<Vec<LiveSession>>> = OnceLock::new();
|
||||
T.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Two identities are the same client iff both are present and equal. Anonymous (`None`) never
|
||||
/// matches — we can't prove it's the same client, so two anonymous clients are treated as distinct
|
||||
/// (each conflicts), which is the safe side for `steal`/`reject`.
|
||||
fn same_client(a: Option<[u8; 32]>, b: Option<[u8; 32]>) -> bool {
|
||||
matches!((a, b), (Some(x), Some(y)) if x == y)
|
||||
}
|
||||
|
||||
/// The mode-conflict decision, pure over the live-session slice (so it's unit-testable). A conflict is
|
||||
/// a live session owned by a DIFFERENT client — a same-client reconnect adopts / reconfigures its own
|
||||
/// display and never conflicts (so it always resolves to `Separate` here and preempts downstream).
|
||||
pub fn decide(
|
||||
conflict: ModeConflict,
|
||||
req_identity: Option<[u8; 32]>,
|
||||
live: &[LiveSession],
|
||||
) -> Admission {
|
||||
let others: Vec<&LiveSession> = live
|
||||
.iter()
|
||||
.filter(|s| !same_client(s.identity, req_identity))
|
||||
.collect();
|
||||
if others.is_empty() {
|
||||
return Admission::Separate; // no other client is live → no conflict
|
||||
}
|
||||
match conflict {
|
||||
ModeConflict::Separate => Admission::Separate,
|
||||
// Join at the OLDEST other session's mode (the established "primary" the desktop is built on).
|
||||
ModeConflict::Join => Admission::Join(others[0].mode),
|
||||
ModeConflict::Steal => {
|
||||
Admission::Steal(others.iter().map(|s| Arc::clone(&s.stop)).collect())
|
||||
}
|
||||
ModeConflict::Reject => {
|
||||
let v = others[0];
|
||||
Admission::Reject(format!(
|
||||
"host busy: streaming {}x{}@{} to {}",
|
||||
v.mode.0, v.mode.1, v.mode.2, v.label
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when
|
||||
/// unconfigured), with the **Windows default applied**. On Windows `separate` — including the
|
||||
/// unconfigured default — still resolves to **`reject`** UNLESS the Stage-W3 validation hatch
|
||||
/// `PUNKTFUNK_WIN_SEPARATE=1` is set (`design/windows-parallel-virtual-displays.md` §4.3 — the
|
||||
/// default flips to real `separate` in W5, after the on-glass matrix is green).
|
||||
///
|
||||
/// The historical `reject` override guarded against a real wedge: two concurrent Windows sessions
|
||||
/// both drove the SAME pf-vdisplay monitor's single-capturer IDD-push channel
|
||||
/// ("newest-delivery-wins"), which froze the live client and could wedge the driver. With the
|
||||
/// manager's slot map (Stage W1) that wedge is structurally impossible — a second identity gets its
|
||||
/// OWN slot → own monitor → own sealed ring — so the override is now a validation-soak guard, not a
|
||||
/// correctness one. `join`/`steal` stay as explicit opt-ins. Linux keeps `separate` (real
|
||||
/// multi-view). Shared by the native + GameStream admission paths.
|
||||
pub fn effective_conflict() -> ModeConflict {
|
||||
let conflict = policy::prefs()
|
||||
.configured_effective()
|
||||
.map(|e| e.mode_conflict)
|
||||
.unwrap_or(ModeConflict::Separate);
|
||||
#[cfg(windows)]
|
||||
if matches!(conflict, ModeConflict::Separate)
|
||||
&& !std::env::var("PUNKTFUNK_WIN_SEPARATE").is_ok_and(|v| v == "1")
|
||||
{
|
||||
return ModeConflict::Reject;
|
||||
}
|
||||
conflict
|
||||
}
|
||||
|
||||
/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`]
|
||||
/// against the live set, then — when a SECOND display would actually be created (`Separate` with
|
||||
/// other clients live, Windows) — the Stage-W3 resource budgets: `max_displays` across the manager's
|
||||
/// live/kept slots, and the encoder's session headroom. Fail-closed at admission
|
||||
/// (`design/windows-parallel-virtual-displays.md` §2.5): a display we can't afford is DECLINED here,
|
||||
/// never admitted-then-degrading a live sibling.
|
||||
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
|
||||
let live = table().lock().unwrap();
|
||||
let decision = decide(effective_conflict(), req_identity, &live);
|
||||
#[cfg(windows)]
|
||||
if matches!(decision, Admission::Separate) && !live.is_empty() {
|
||||
let max = policy::prefs().get().effective().max_displays;
|
||||
let slots = super::manager::snapshot().len() as u32;
|
||||
if slots >= max {
|
||||
return Admission::Reject(format!(
|
||||
"host display budget exhausted: {slots} display(s) live/kept, max_displays = {max}"
|
||||
));
|
||||
}
|
||||
if !crate::encode::can_open_another_session() {
|
||||
return Admission::Reject(
|
||||
"host encoder budget exhausted: no NVENC session headroom for another display"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
decision
|
||||
}
|
||||
|
||||
/// Pure core of [`preempt_same_identity`]: the stop flags of live sessions owned by the SAME client
|
||||
/// as `req_identity` (its own zombies). Testable over a slice (the public fn locks the global table).
|
||||
fn same_identity_stops(
|
||||
req_identity: Option<[u8; 32]>,
|
||||
live: &[LiveSession],
|
||||
) -> Vec<Arc<AtomicBool>> {
|
||||
live.iter()
|
||||
.filter(|s| same_client(s.identity, req_identity))
|
||||
.map(|s| Arc::clone(&s.stop))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Preempt this reconnecting client's OWN still-live session(s). A client has at most one live
|
||||
/// session, so a new connection from an already-registered identity is a **reconnect** — the old
|
||||
/// session is a zombie whose QUIC idle timer hasn't fired yet (an unwanted disconnect is only
|
||||
/// declared dead after `max_idle_timeout`, ~seconds later). Return its stop flag(s) so the caller
|
||||
/// signals them and waits the release grace: the zombie tears its display down, which (keep-alive on)
|
||||
/// lingers, and THIS reconnect **reuses** that kept display instead of landing on a fresh SECOND one
|
||||
/// (the "thrown onto a second display while the old one keeps streaming" bug). Anonymous (`None`)
|
||||
/// never matches — same limitation as `steal`/`reject`. Call this BEFORE [`admit`] and before this
|
||||
/// session registers itself, so it only ever signals a *prior* session's flag, never its own.
|
||||
pub fn preempt_same_identity(req_identity: Option<[u8; 32]>) -> Vec<Arc<AtomicBool>> {
|
||||
same_identity_stops(req_identity, &table().lock().unwrap())
|
||||
}
|
||||
|
||||
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
|
||||
/// AFTER [`admit`] (so a session never conflicts with itself) and once the mode + stop flag are known.
|
||||
pub fn register(
|
||||
identity: Option<[u8; 32]>,
|
||||
mode: (u32, u32, u32),
|
||||
stop: Arc<AtomicBool>,
|
||||
label: String,
|
||||
) -> LiveGuard {
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
table().lock().unwrap().push(LiveSession {
|
||||
id,
|
||||
identity,
|
||||
mode,
|
||||
stop,
|
||||
label,
|
||||
});
|
||||
LiveGuard { id }
|
||||
}
|
||||
|
||||
/// RAII handle: removes its live-session entry from the registry on drop (session end).
|
||||
pub struct LiveGuard {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl Drop for LiveGuard {
|
||||
fn drop(&mut self) {
|
||||
table().lock().unwrap().retain(|s| s.id != self.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sess(identity: Option<u8>, mode: (u32, u32, u32)) -> LiveSession {
|
||||
LiveSession {
|
||||
id: 0,
|
||||
identity: identity.map(|n| {
|
||||
let mut f = [0u8; 32];
|
||||
f[0] = n;
|
||||
f
|
||||
}),
|
||||
mode,
|
||||
stop: Arc::new(AtomicBool::new(false)),
|
||||
label: "peer".into(),
|
||||
}
|
||||
}
|
||||
fn fp(n: u8) -> Option<[u8; 32]> {
|
||||
let mut f = [0u8; 32];
|
||||
f[0] = n;
|
||||
Some(f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_live_session_is_always_separate() {
|
||||
for c in [
|
||||
ModeConflict::Separate,
|
||||
ModeConflict::Join,
|
||||
ModeConflict::Steal,
|
||||
ModeConflict::Reject,
|
||||
] {
|
||||
assert!(matches!(decide(c, fp(1), &[]), Admission::Separate));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_client_never_conflicts() {
|
||||
let live = [sess(Some(1), (2560, 1440, 60))];
|
||||
// Even under reject/steal, the SAME client (fp 1) reconnecting is not a conflict.
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Reject, fp(1), &live),
|
||||
Admission::Separate
|
||||
));
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Steal, fp(1), &live),
|
||||
Admission::Separate
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_client_applies_policy() {
|
||||
let live = [sess(Some(1), (2560, 1440, 60))];
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Separate, fp(2), &live),
|
||||
Admission::Separate
|
||||
));
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Join, fp(2), &live),
|
||||
Admission::Join((2560, 1440, 60))
|
||||
));
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Steal, fp(2), &live),
|
||||
Admission::Steal(v) if v.len() == 1
|
||||
));
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Reject, fp(2), &live),
|
||||
Admission::Reject(r) if r.contains("2560x1440@60")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_anonymous_clients_conflict() {
|
||||
// Anonymous (None) can't be proven same-client, so a second anon client DOES conflict.
|
||||
let live = [sess(None, (1920, 1080, 60))];
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Reject, None, &live),
|
||||
Admission::Reject(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_identity_stops_targets_own_zombie_only() {
|
||||
let live = [
|
||||
sess(Some(1), (2560, 1440, 60)), // this client's prior (zombie) session
|
||||
sess(Some(2), (1920, 1080, 60)), // a different client
|
||||
];
|
||||
// Reconnecting as client 1 → its own zombie's stop is returned (to preempt), not client 2's.
|
||||
assert_eq!(same_identity_stops(fp(1), &live).len(), 1);
|
||||
// A client with no prior session (fp 3) has nothing of its own to preempt.
|
||||
assert_eq!(same_identity_stops(fp(3), &live).len(), 0);
|
||||
// Anonymous never matches — we can't prove it's the same client.
|
||||
assert_eq!(same_identity_stops(None, &live).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_targets_the_oldest_other_session() {
|
||||
let live = [
|
||||
sess(Some(1), (3840, 2160, 60)), // oldest
|
||||
sess(Some(2), (1280, 720, 120)),
|
||||
];
|
||||
assert!(matches!(
|
||||
decide(ModeConflict::Join, fp(3), &live),
|
||||
Admission::Join((3840, 2160, 60))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! Virtual-display backend contract (plan §W3 — the trait facade carved out of [`super`]).
|
||||
//! [`DisplayOwnership`] declares who owns an output's lifecycle, [`VirtualOutput`] is the created
|
||||
//! output (PipeWire node + RAII keepalive), and [`VirtualDisplay`] is the per-compositor backend
|
||||
//! trait `super::open` returns boxed. The per-backend `impl`s and the factory stay in `super`.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Who owns a [`VirtualOutput`]'s lifecycle — the honest declaration that lets the registry
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` Part A1) pool **only what it owns** instead of
|
||||
/// keeping outputs whose real lifecycle lives elsewhere (the gamescope managed/attach paths, which
|
||||
/// are governed by the gamescope module's own session machinery). Extends the CLAUDE.md invariant
|
||||
/// "the registry owns display lifecycle" with its converse: what the registry does not own, it must
|
||||
/// not pretend to keep.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DisplayOwnership {
|
||||
/// The registry owns the lifecycle: it may pool, linger, pin, and tear this display down (KWin,
|
||||
/// Mutter, wlroots, gamescope **bare spawn**, and the Windows manager-delegated monitor). The
|
||||
/// default — a backend that says nothing is registry-owned.
|
||||
#[default]
|
||||
Owned,
|
||||
/// Someone else's display, merely mirrored: no keep-alive, no topology, no reuse (gamescope
|
||||
/// **attach** to a foreign session). Codifies the design-doc §7 "attach = unmanaged pass-through"
|
||||
/// row.
|
||||
External,
|
||||
/// A box-level session the gamescope module manages (the managed `gamescope-session-plus` /
|
||||
/// SteamOS takeover). Passed through by the registry (its restore lifecycle is the gamescope
|
||||
/// module's until Part A3 hands the registry a real keepalive + restore duty).
|
||||
SessionManaged,
|
||||
}
|
||||
|
||||
/// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop
|
||||
/// tears the output down (releases the compositor-side resource).
|
||||
///
|
||||
/// Allowed dead on non-Linux: the backends that construct it are all `cfg(target_os = "linux")`.
|
||||
#[allow(dead_code)]
|
||||
pub struct VirtualOutput {
|
||||
/// PipeWire node id of the output's screencast stream.
|
||||
pub node_id: u32,
|
||||
/// Portal/remote PipeWire fd when the node lives on a sandboxed remote (e.g. Mutter's
|
||||
/// RemoteDesktop+ScreenCast). `None` means the node is on the user's default PipeWire daemon
|
||||
/// (KWin `zkde_screencast`), captured by connecting to that daemon directly.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub remote_fd: Option<OwnedFd>,
|
||||
/// `(width, height, refresh_hz)` to prefer in the PipeWire format negotiation. KWin and
|
||||
/// gamescope outputs are created at the exact size, so this just confirms it; **Mutter sizes
|
||||
/// its virtual monitor FROM the negotiation**, so here it's what makes the client's mode real.
|
||||
pub preferred_mode: Option<(u32, u32, u32)>,
|
||||
/// Windows capture identity (DXGI adapter LUID + GDI output name) for the pf-vdisplay backend —
|
||||
/// what [`crate::capture::capture_virtual_output`] needs to duplicate the right output.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>,
|
||||
/// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown.
|
||||
pub keepalive: Box<dyn Send>,
|
||||
/// Who owns this display's lifecycle (`design/gamemode-and-dedicated-sessions.md` A1). The
|
||||
/// registry pools/keep-alives only [`DisplayOwnership::Owned`] outputs; `External`/`SessionManaged`
|
||||
/// pass through (the capturer holds the keepalive, teardown on drop). Defaults to `Owned`.
|
||||
pub ownership: DisplayOwnership,
|
||||
/// `Some(gen)` when [`registry::acquire`](crate::vdisplay::registry::acquire) handed this back as a
|
||||
/// **reused** kept display (`design/gamemode-and-dedicated-sessions.md` A2), so the pipeline builder
|
||||
/// can [`registry::mark_failed(gen)`](crate::vdisplay::registry::mark_failed) if the first frame
|
||||
/// fails on it — tearing the corpse down so the retry loop's next acquire creates fresh instead of
|
||||
/// re-wedging on the same dead node. `None` on a fresh create / non-poolable output. Linux-only (the
|
||||
/// keep-alive pool is Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub reused_gen: Option<u64>,
|
||||
/// The registry pool generation of this display (fresh AND reused — unlike `reused_gen`), so a
|
||||
/// mid-stream mode-switch rebuild can [`registry::retire`](crate::vdisplay::registry::retire) the
|
||||
/// display it supersedes instead of leaving it to accumulate under a linger/forever keep-alive
|
||||
/// policy (`design/midstream-resolution-resize.md` H4). `None` for non-poolable outputs.
|
||||
/// Linux-only (the keep-alive pool is Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub pool_gen: Option<u64>,
|
||||
}
|
||||
|
||||
impl VirtualOutput {
|
||||
/// A registry-[owned](DisplayOwnership::Owned) output — the common case (KWin/Mutter/wlroots,
|
||||
/// gamescope bare-spawn, Windows). Fills `ownership: Owned`; the caller sets the platform fields.
|
||||
pub fn owned(
|
||||
node_id: u32,
|
||||
preferred_mode: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id,
|
||||
#[cfg(target_os = "linux")]
|
||||
remote_fd: None,
|
||||
preferred_mode,
|
||||
#[cfg(target_os = "windows")]
|
||||
win_capture: None,
|
||||
keepalive,
|
||||
ownership: DisplayOwnership::Owned,
|
||||
#[cfg(target_os = "linux")]
|
||||
reused_gen: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
pool_gen: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pluggable virtual-output creation, per compositor.
|
||||
pub trait VirtualDisplay: Send {
|
||||
/// Human-readable backend name (e.g. `"kwin"`, `"wlroots"`, `"mutter"`).
|
||||
fn name(&self) -> &'static str;
|
||||
/// Create a virtual output of the given mode. Teardown is RAII: drop the returned
|
||||
/// [`VirtualOutput`]'s `keepalive`.
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput>;
|
||||
/// Set the per-session command this display should launch into its nested output (the resolved
|
||||
/// app/game). Carried on the backend instance — NOT a process-global env var — so concurrent
|
||||
/// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an
|
||||
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
|
||||
fn set_launch_command(&mut self, _cmd: Option<String>) {}
|
||||
/// Set the connecting client's cert fingerprint so the backend can give that client a STABLE virtual
|
||||
/// monitor identity across reconnects and its saved per-monitor config (notably DPI scaling) is
|
||||
/// reapplied — via the OS (Windows EDID serial), the compositor (KWin per-slot output name), or
|
||||
/// host-side persistence (Mutter, whose virtual monitors can't carry a stable identity). Carried on
|
||||
/// the backend instance; set once before [`create`](Self::create). Default: no-op (wlroots/gamescope
|
||||
/// have no per-client identity). `None` = anonymous/unpaired/GameStream → the backend's auto
|
||||
/// (slot-based/shared) identity.
|
||||
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
|
||||
/// Hand the backend the session's deliberate-quit flag (set when the client closes with the QUIT
|
||||
/// application code — a user "stop", not a network drop) so the last lease's drop can tear the
|
||||
/// display down IMMEDIATELY, skipping the keep-alive linger — the Windows analogue of the Linux
|
||||
/// registry's `Linger::Immediate` path. Carried on the backend instance; set once before
|
||||
/// [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay backend needs it (its
|
||||
/// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach;
|
||||
/// Linux backends get the flag through `registry::acquire`).
|
||||
fn set_quit_flag(&mut self, _quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {}
|
||||
/// Hand the backend the CLIENT display's HDR colour volume (`Hello::display_hdr` — primaries /
|
||||
/// white point / luminance range as reported by the client OS), so a freshly created virtual
|
||||
/// output can advertise the client's REAL panel in its EDID (pf-vdisplay codes the luminance
|
||||
/// into the CTA-861.3 HDR static-metadata block) — host apps and the OS then tone-map to the
|
||||
/// panel the stream actually lands on instead of a built-in placeholder volume. Carried on the
|
||||
/// backend instance; set once before [`create`](Self::create). `None` = unknown/SDR client →
|
||||
/// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint
|
||||
/// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us).
|
||||
fn set_client_hdr(&mut self, _hdr: Option<punktfunk_core::quic::HdrMeta>) {}
|
||||
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
|
||||
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
|
||||
/// registry reads it right after `create` to key the display's group **arrangement** (manual
|
||||
/// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend
|
||||
/// with no per-client identity (wlroots/gamescope) always auto-rows. KWin (per-slot output
|
||||
/// naming) and Mutter (host-persisted per-client scale) report a real slot on Linux.
|
||||
fn last_identity_slot(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
/// Place the most-recently-[created](Self::create) output at `(x, y)` in the desktop coordinate
|
||||
/// space (design `display-management.md` §6.2 — layout). The registry, which owns the display
|
||||
/// **group**, computes the position from the whole group (auto-row or the console's manual
|
||||
/// arrangement) and calls this right after `create`. Default no-op: only backends that can position
|
||||
/// an output (KWin) implement it; the registry never calls it for the desktop origin `(0, 0)`, so a
|
||||
/// single-display / first-of-group session issues no positioning at all. Best-effort — a failure
|
||||
/// leaves the compositor's default placement.
|
||||
fn apply_position(&mut self, _x: i32, _y: i32) {}
|
||||
/// Take the topology **restore** action this [`create`](Self::create) prepared — the work that
|
||||
/// un-does an `exclusive`/`primary` topology change (e.g. re-enable the physical outputs KWin
|
||||
/// disabled). The registry lifts it into the display **group** so it runs **once, when the group's
|
||||
/// last display is torn down** (design §6.1 — per-group restore), not when this one session's
|
||||
/// display drops: a sibling `exclusive` session must not have the physical re-enabled under it.
|
||||
/// Called right after `create`; the backend must not also run it itself. Default `None` — a backend
|
||||
/// whose topology auto-reverts (Mutter `APPLY_TEMPORARY`) or that changes nothing has nothing to
|
||||
/// hand off.
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
None
|
||||
}
|
||||
/// Tell the backend whether this create will be the **first** display in its group — i.e. no
|
||||
/// sibling of the same backend is already live (design §6.1). A backend that *establishes* the
|
||||
/// group's topology (Mutter's sole-monitor `exclusive` `ApplyMonitorsConfig`) applies it only when
|
||||
/// first; a later sibling **extends** into the already-exclusive desktop instead of re-clobbering it
|
||||
/// (a fresh sole-monitor config would disable the first session's virtual output). Set by the
|
||||
/// registry right before [`create`](Self::create). Default no-op: KWin recognises siblings at
|
||||
/// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display
|
||||
/// backends never have a sibling.
|
||||
fn set_first_in_group(&mut self, _first: bool) {}
|
||||
/// Will a [`create`](Self::create) for the CURRENT request produce a registry-poolable
|
||||
/// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before**
|
||||
/// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of
|
||||
/// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn**
|
||||
/// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it,
|
||||
/// returning `false` when the env selects attach/managed (consistent with the `ownership` its
|
||||
/// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1.
|
||||
fn poolable_now(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The resolved launch command carried on this backend instance (set via
|
||||
/// [`set_launch_command`](Self::set_launch_command)). The registry reads it to key keep-alive reuse
|
||||
/// on `(backend, mode, launch)` (`design/gamemode-and-dedicated-sessions.md` A2) — a kept display
|
||||
/// running game A must never be handed to a session that asked to launch game B. Default `None`
|
||||
/// (backends that never nest a command); only gamescope reports its `cmd`.
|
||||
fn launch_command(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Is the kept display's `node_id` still live, checked **before** the registry REUSES it on a
|
||||
/// reconnect (`design/gamemode-and-dedicated-sessions.md` A2)? A `false` tells the registry to tear
|
||||
/// the dead entry down and create fresh instead of handing back a corpse (which would then fail
|
||||
/// capture and burn a retry). Default `true` (honest optimism — the [`mark_failed`] path is the
|
||||
/// backstop for a display that dies between this check and first frame). Only gamescope overrides
|
||||
/// it (its nested session dies when the game exits, independently of any compositor); KWin/Mutter
|
||||
/// nodes die only with their compositor, which the session-epoch invalidation (A4) already reaps.
|
||||
///
|
||||
/// [`mark_failed`]: crate::vdisplay::registry::mark_failed
|
||||
fn kept_display_alive(&mut self, _node_id: u32) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
//! Platform-neutral **per-client → stable display-id map** (design: `design/display-management.md`
|
||||
//! §5.4 — identity). A client that reconnects gets the SAME small stable id every time, so the
|
||||
//! desktop environment can key its per-display config (notably **DPI scaling**) to it and reapply it:
|
||||
//!
|
||||
//! * **Windows** seeds the pf-vdisplay monitor's EDID serial + IddCx `ConnectorIndex` from the id, so
|
||||
//! Windows reapplies the client's saved `PerMonitorSettings` scaling. The id must stay `1..=15`
|
||||
//! (`ConnectorIndex < MaxMonitorsSupported = 16`).
|
||||
//! * **KWin** names the streamed output `Virtual-punktfunk-<id>`; KWin persists per-output scale/mode
|
||||
//! in `kwinoutputconfig.json` matched by name, so a stable per-client name makes KDE reapply that
|
||||
//! client's scaling. (Generalised here from the Windows-only map; the KWin wiring is Stage 3.)
|
||||
//! * **Mutter** can't carry the id into its virtual monitor (fresh EDID serial per `RecordVirtual`,
|
||||
//! no API to override), so GNOME's `monitors.xml` never rematches — the host persists the scale
|
||||
//! itself instead ([`ScaleMap`], keyed by the same [`identity_key`]) and the Mutter backend
|
||||
//! reapplies it. The slot id still keys the registry's group arrangement/state.
|
||||
//!
|
||||
//! The map key is a composable string ([`identity_key`]): the client cert fingerprint alone
|
||||
//! (`per-client`), or fingerprint + resolution (`per-client-mode` — distinct scaling per resolution).
|
||||
//! Anonymous/TOFU/GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream,
|
||||
//! never reaching this map.
|
||||
//!
|
||||
//! Persisted to `<config>/display-identity.json` (migrated from the legacy Windows
|
||||
//! `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};
|
||||
|
||||
/// Max stable id. Bounded by the Windows driver's use of the id as the IddCx `ConnectorIndex`
|
||||
/// (`< MaxMonitorsSupported = 16`), so ids run `1..=15` on every platform for a single shared map.
|
||||
const MAX_ID: u32 = 15;
|
||||
|
||||
/// The map filename (migrated from the legacy Windows-only `pf-vdisplay-identity.json`).
|
||||
const FILE: &str = "display-identity.json";
|
||||
const LEGACY_FILE: &str = "pf-vdisplay-identity.json";
|
||||
|
||||
/// Compose the map key for a client. `per_client_mode` appends the resolution so a client keeps a
|
||||
/// distinct id (and thus distinct persisted scaling) per resolution; otherwise the fingerprint alone.
|
||||
pub(crate) fn identity_key(fp: [u8; 32], mode: (u32, u32), per_client_mode: bool) -> String {
|
||||
let hex: String = fp.iter().map(|b| format!("{b:02x}")).collect();
|
||||
if per_client_mode {
|
||||
format!("{hex}@{}x{}", mode.0, mode.1)
|
||||
} else {
|
||||
hex
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct Store {
|
||||
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
|
||||
/// the LRU ordering survives host restarts.
|
||||
tick: u64,
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Entry {
|
||||
/// The composed client key ([`identity_key`]) — the map key. (Serialized as `fp` for
|
||||
/// back-compat with the legacy Windows `pf-vdisplay-identity.json`.)
|
||||
#[serde(rename = "fp")]
|
||||
key: String,
|
||||
/// The client's stable display id (`1..=15`).
|
||||
id: u32,
|
||||
/// MRU stamp (compared against [`Store::tick`]).
|
||||
seen: u64,
|
||||
}
|
||||
|
||||
/// Persistent client-key → stable-id map (see the module docs).
|
||||
pub(crate) struct DisplayIdentityMap {
|
||||
path: PathBuf,
|
||||
store: Store,
|
||||
}
|
||||
|
||||
impl DisplayIdentityMap {
|
||||
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
|
||||
/// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy
|
||||
/// Windows `pf-vdisplay-identity.json` if the new file is absent.
|
||||
pub(crate) fn load() -> Self {
|
||||
let dir = pf_paths::config_dir();
|
||||
let path = dir.join(FILE);
|
||||
let bytes = std::fs::read(&path)
|
||||
.or_else(|_| std::fs::read(dir.join(LEGACY_FILE)))
|
||||
.ok();
|
||||
let mut store = bytes
|
||||
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
|
||||
.unwrap_or_default();
|
||||
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s
|
||||
// found-entry branch returns the stored id verbatim, so an out-of-range id (0 = the "auto"
|
||||
// sentinel, or > MAX_ID) or a duplicate id/key would flow straight into the display identity.
|
||||
// Drop out-of-range ids and dedup by BOTH key and id (keeping the most-recently-seen on a
|
||||
// clash) so no two clients can map to the same id.
|
||||
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
|
||||
let mut seen_key = std::collections::HashSet::new();
|
||||
let mut seen_id = std::collections::HashSet::new();
|
||||
store.entries.retain(|e| {
|
||||
(1..=MAX_ID).contains(&e.id) && seen_key.insert(e.key.clone()) && seen_id.insert(e.id)
|
||||
});
|
||||
Self { path, store }
|
||||
}
|
||||
|
||||
/// The stable id (`1..=15`) for the client `key` ([`identity_key`]): its remembered id, or a
|
||||
/// freshly assigned one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
|
||||
pub(crate) fn resolve(&mut self, key: &str) -> u32 {
|
||||
self.store.tick = self.store.tick.wrapping_add(1);
|
||||
let now = self.store.tick;
|
||||
|
||||
if let Some(e) = self.store.entries.iter_mut().find(|e| e.key == key) {
|
||||
e.seen = now;
|
||||
let id = e.id;
|
||||
self.persist();
|
||||
return id;
|
||||
}
|
||||
|
||||
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
|
||||
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
|
||||
let id = (1..=MAX_ID)
|
||||
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
|
||||
.unwrap_or_else(|| {
|
||||
let lru = self
|
||||
.store
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, e)| e.seen)
|
||||
.map(|(i, _)| i)
|
||||
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
|
||||
let evicted = self.store.entries.remove(lru);
|
||||
evicted.id
|
||||
});
|
||||
self.store.entries.push(Entry {
|
||||
key: key.to_string(),
|
||||
id,
|
||||
seen: now,
|
||||
});
|
||||
self.persist();
|
||||
id
|
||||
}
|
||||
|
||||
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
|
||||
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
|
||||
fn persist(&self) {
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide identity map (persisted, loaded once). Shared by the Windows manager and the
|
||||
/// Linux KWin/Mutter backends — 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)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Per-client persisted desktop scale (`<config>/display-scale.json`) — the Mutter carrier
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// The scale-map filename.
|
||||
const SCALE_FILE: &str = "display-scale.json";
|
||||
|
||||
/// The key under which a client's desktop **scale** is persisted: the [`identity_key`] under a
|
||||
/// per-client policy, or the fixed `"shared"` slot under a Shared policy / for anonymous sessions
|
||||
/// (can't collide — identity keys are 64 hex chars). Same policy resolution as [`resolve_slot`].
|
||||
pub(crate) fn scale_key(
|
||||
fp: Option<[u8; 32]>,
|
||||
mode: (u32, u32),
|
||||
default: crate::vdisplay::policy::Identity,
|
||||
) -> String {
|
||||
let id_policy = crate::vdisplay::policy::prefs()
|
||||
.configured_effective()
|
||||
.map(|e| e.identity)
|
||||
.unwrap_or(default);
|
||||
scale_key_for(id_policy, fp, mode)
|
||||
}
|
||||
|
||||
/// Pure core of [`scale_key`] (policy already resolved) — unit-testable without the global store.
|
||||
fn scale_key_for(
|
||||
policy: crate::vdisplay::policy::Identity,
|
||||
fp: Option<[u8; 32]>,
|
||||
mode: (u32, u32),
|
||||
) -> String {
|
||||
use crate::vdisplay::policy::Identity;
|
||||
match (policy, fp) {
|
||||
(Identity::Shared, _) | (_, None) => "shared".to_string(),
|
||||
(Identity::PerClient, Some(fp)) => identity_key(fp, mode, false),
|
||||
(Identity::PerClientMode, Some(fp)) => identity_key(fp, mode, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent client-key → desktop-scale map. Windows and KDE persist per-display scaling
|
||||
/// themselves once the backend gives the monitor a stable identity — but GNOME **cannot**: Mutter
|
||||
/// mints a fresh EDID serial (`0x%.6x`, a per-shell counter) for every `RecordVirtual` monitor, so
|
||||
/// the `monitors.xml` entry GNOME writes never rematches on reconnect, and `RecordVirtual` offers
|
||||
/// no way to pass a stable identity. The host therefore remembers the scale itself; the Mutter
|
||||
/// backend reapplies it (`preferred-scale` + its topology `ApplyMonitorsConfig`) and records the
|
||||
/// user's in-session changes here.
|
||||
pub(crate) struct ScaleMap {
|
||||
path: PathBuf,
|
||||
map: std::collections::BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
impl ScaleMap {
|
||||
/// Load the persisted map (empty on first run / unreadable file — a client just re-sets its
|
||||
/// scaling once). Drops non-finite / out-of-range entries from a hand-edited file.
|
||||
fn load() -> Self {
|
||||
let path = pf_paths::config_dir().join(SCALE_FILE);
|
||||
let mut map: std::collections::BTreeMap<String, f64> = std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
map.retain(|_, s| s.is_finite() && (0.25..=8.0).contains(s));
|
||||
Self { path, map }
|
||||
}
|
||||
|
||||
/// The remembered scale for `key`, if any.
|
||||
pub(crate) fn get(&self, key: &str) -> Option<f64> {
|
||||
self.map.get(key).copied()
|
||||
}
|
||||
|
||||
/// Remember `scale` for `key` and persist (atomic temp-write + rename, best-effort — a failed
|
||||
/// write costs one scaling re-set after a restart).
|
||||
pub(crate) fn set(&mut self, key: &str, scale: f64) {
|
||||
if !scale.is_finite() || !(0.25..=8.0).contains(&scale) {
|
||||
return;
|
||||
}
|
||||
self.map.insert(key.to_string(), scale);
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&self.map) else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide scale map (persisted, loaded once) — used by the Mutter backend only (see
|
||||
/// [`ScaleMap`]).
|
||||
pub(crate) fn scales() -> &'static Mutex<ScaleMap> {
|
||||
static MAP: OnceLock<Mutex<ScaleMap>> = OnceLock::new();
|
||||
MAP.get_or_init(|| Mutex::new(ScaleMap::load()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fp(n: u8) -> [u8; 32] {
|
||||
let mut f = [0u8; 32];
|
||||
f[0] = n;
|
||||
f
|
||||
}
|
||||
|
||||
fn temp_map(tag: &str) -> DisplayIdentityMap {
|
||||
DisplayIdentityMap {
|
||||
path: std::env::temp_dir().join(format!("pf-id-{tag}-{}.json", std::process::id())),
|
||||
store: Store::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_across_calls_and_distinct_per_client() {
|
||||
let mut m = temp_map("stable");
|
||||
let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false));
|
||||
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false));
|
||||
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false)); // per-client: mode ignored
|
||||
assert_eq!(a1, a2, "same client → same id (per-client ignores mode)");
|
||||
assert_ne!(a1, b, "distinct clients → distinct ids");
|
||||
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
|
||||
let _ = std::fs::remove_file(&m.path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_client_mode_splits_by_resolution() {
|
||||
let mut m = temp_map("permode");
|
||||
let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true));
|
||||
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true));
|
||||
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true));
|
||||
assert_ne!(hd, uhd, "same client, different resolution → different id");
|
||||
assert_eq!(hd, hd2, "same client + resolution → same id");
|
||||
let _ = std::fs::remove_file(&m.path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lru_eviction_reuses_an_id_at_the_cap() {
|
||||
let mut m = temp_map("lru");
|
||||
for n in 1..=15u8 {
|
||||
m.resolve(&identity_key(fp(n), (1920, 1080), false));
|
||||
}
|
||||
let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false)); // touch 2 so 1 is LRU
|
||||
let id16 = m.resolve(&identity_key(fp(16), (1920, 1080), false));
|
||||
assert!((1..=MAX_ID).contains(&id16));
|
||||
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
|
||||
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
|
||||
let _ = std::fs::remove_file(&m.path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_composition() {
|
||||
assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only
|
||||
assert!(identity_key(fp(0xab), (1920, 1080), true).ends_with("@1920x1080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_key_follows_the_identity_policy() {
|
||||
use crate::vdisplay::policy::Identity;
|
||||
// Shared / anonymous → the fixed shared slot.
|
||||
assert_eq!(
|
||||
scale_key_for(Identity::Shared, Some(fp(1)), (1920, 1080)),
|
||||
"shared"
|
||||
);
|
||||
assert_eq!(
|
||||
scale_key_for(Identity::PerClient, None, (1920, 1080)),
|
||||
"shared"
|
||||
);
|
||||
// Per-client → the fingerprint key; per-client-mode appends the resolution.
|
||||
let pc = scale_key_for(Identity::PerClient, Some(fp(1)), (1920, 1080));
|
||||
assert_eq!(pc, identity_key(fp(1), (1920, 1080), false));
|
||||
let pcm = scale_key_for(Identity::PerClientMode, Some(fp(1)), (1920, 1080));
|
||||
assert!(pcm.ends_with("@1920x1080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_map_roundtrips_and_rejects_junk() {
|
||||
let path = std::env::temp_dir().join(format!("pf-scale-{}.json", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut m = ScaleMap {
|
||||
path: path.clone(),
|
||||
map: Default::default(),
|
||||
};
|
||||
assert_eq!(m.get("k"), None);
|
||||
m.set("k", 1.5);
|
||||
m.set("bad-nan", f64::NAN); // ignored
|
||||
m.set("bad-range", 100.0); // ignored
|
||||
assert_eq!(m.get("k"), Some(1.5));
|
||||
assert_eq!(m.get("bad-nan"), None);
|
||||
assert_eq!(m.get("bad-range"), None);
|
||||
// Persisted: a fresh map from the same path sees the value.
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let reread: std::collections::BTreeMap<String, f64> =
|
||||
serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(reread.get("k"), Some(&1.5));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
//! Pure display-**arrangement** engine (design: `design/display-management.md` §6.2). Given a
|
||||
//! group's members (in acquire order) and the `layout` policy, compute each member's top-left
|
||||
//! origin in the desktop coordinate space. No I/O, no OS types — the registry (for the
|
||||
//! `/display/state` readout) and the per-backend position apply both consume it, so the auto-row /
|
||||
//! manual math is defined and tested in exactly one place (the `pick_gamescope_mode` / `wiring_plan`
|
||||
//! discipline).
|
||||
//!
|
||||
//! * **auto-row** — left-to-right in acquire order, top-aligned: member *i* sits at
|
||||
//! `x = Σ widths[0..i]`, `y = 0`. This is what compositors mostly do by default, made
|
||||
//! deterministic.
|
||||
//! * **manual** — per-identity-slot offsets from [`Layout::positions`] (console-arranged): a member
|
||||
//! whose stable identity slot has a stored position sits there; a member with no pin (no stored
|
||||
//! position, or a shared/anonymous identity that has no slot) falls back to its auto-row origin, so
|
||||
//! a half-arranged group never collapses everything onto the origin.
|
||||
//!
|
||||
//! Group membership + acquire order live in the registry ([`super::registry`]); this file only turns
|
||||
//! that ordered member list into positions.
|
||||
|
||||
use super::policy::{Layout, LayoutMode};
|
||||
|
||||
/// One display in a group, as the arranger sees it (given in acquire order).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Member {
|
||||
/// Stable per-client identity slot — the manual-layout key. `None` for a shared/anonymous
|
||||
/// identity (no per-client slot), which can't carry a manual pin and therefore always auto-rows.
|
||||
pub identity_slot: Option<u32>,
|
||||
/// Pixel width, for auto-row `x` accumulation. Clamped at 0 (a bogus negative never shifts a
|
||||
/// sibling left).
|
||||
pub width: i32,
|
||||
}
|
||||
|
||||
/// A member's resolved desktop-space top-left origin.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Placement {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// The auto-row origin of member `i`: the summed width of every prior member, top-aligned.
|
||||
fn auto_row_x(members: &[Member], i: usize) -> i32 {
|
||||
members[..i].iter().map(|m| m.width.max(0)).sum()
|
||||
}
|
||||
|
||||
/// Arrange `members` (in acquire order) per `layout`, returning one [`Placement`] per member in the
|
||||
/// same order. Pure — the single source of truth for auto-row / manual placement, shared by the
|
||||
/// state readout and (KWin) the per-backend position apply.
|
||||
pub fn arrange(members: &[Member], layout: &Layout) -> Vec<Placement> {
|
||||
members
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let auto = Placement {
|
||||
x: auto_row_x(members, i),
|
||||
y: 0,
|
||||
};
|
||||
match layout.mode {
|
||||
LayoutMode::AutoRow => auto,
|
||||
// A pinned member sits at its stored offset; an unpinned one falls back to auto-row.
|
||||
LayoutMode::Manual => m
|
||||
.identity_slot
|
||||
.and_then(|slot| layout.positions.get(&slot.to_string()))
|
||||
.map(|p| Placement { x: p.x, y: p.y })
|
||||
.unwrap_or(auto),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vdisplay::policy::Position;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn m(slot: Option<u32>, width: i32) -> Member {
|
||||
Member {
|
||||
identity_slot: slot,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
fn manual(pairs: &[(&str, i32, i32)]) -> Layout {
|
||||
let mut positions = BTreeMap::new();
|
||||
for (k, x, y) in pairs {
|
||||
positions.insert(k.to_string(), Position { x: *x, y: *y });
|
||||
}
|
||||
Layout {
|
||||
mode: LayoutMode::Manual,
|
||||
positions,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_row_accumulates_widths_top_aligned() {
|
||||
let members = [m(Some(1), 2560), m(Some(2), 1920), m(None, 1280)];
|
||||
let out = arrange(&members, &Layout::default()); // default = AutoRow
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![
|
||||
Placement { x: 0, y: 0 },
|
||||
Placement { x: 2560, y: 0 },
|
||||
Placement { x: 4480, y: 0 },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_honors_pins_by_identity_slot() {
|
||||
let members = [m(Some(1), 2560), m(Some(7), 1920)];
|
||||
// Client 7 arranged to the LEFT of client 1 (crossing order reversed vs auto-row).
|
||||
let layout = manual(&[("1", 1920, 0), ("7", 0, 0)]);
|
||||
let out = arrange(&members, &layout);
|
||||
assert_eq!(out[0], Placement { x: 1920, y: 0 });
|
||||
assert_eq!(out[1], Placement { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_unpinned_and_slotless_fall_back_to_auto_row() {
|
||||
let members = [m(Some(1), 2560), m(Some(9), 1920), m(None, 1280)];
|
||||
// Only slot 1 is pinned; slot 9 has no stored pin; the third has no slot at all.
|
||||
let layout = manual(&[("1", 100, 50)]);
|
||||
let out = arrange(&members, &layout);
|
||||
assert_eq!(out[0], Placement { x: 100, y: 50 }, "pinned");
|
||||
assert_eq!(out[1], Placement { x: 2560, y: 0 }, "unpinned → auto-row");
|
||||
assert_eq!(out[2], Placement { x: 4480, y: 0 }, "slotless → auto-row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_group_is_empty() {
|
||||
assert!(arrange(&[], &Layout::default()).is_empty());
|
||||
assert!(arrange(&[], &manual(&[("1", 0, 0)])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_width_never_shifts_siblings_left() {
|
||||
let members = [m(Some(1), -100), m(Some(2), 1920)];
|
||||
let out = arrange(&members, &Layout::default());
|
||||
let origin = Placement { x: 0, y: 0 };
|
||||
assert_eq!(out[0], origin);
|
||||
assert_eq!(out[1], origin, "clamped width contributes 0");
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
//! Pure per-display **lifecycle state machine** (design: `design/display-management.md` §3).
|
||||
//!
|
||||
//! One virtual display's earned refcount + linger + pin state, with **no I/O and no OS-specific
|
||||
//! types** — the registry ([`super::registry`]) executes the side effects (backend create /
|
||||
//! teardown / linger timer) that this machine's transitions dictate. Extracted so the lifecycle
|
||||
//! logic is unit- and property-testable in isolation, and so the Linux registry and (later) the
|
||||
//! Windows manager share one audited machine instead of each re-deriving refcount+linger by hand.
|
||||
//!
|
||||
//! It is the platform-neutral distillation of the model the Windows `VirtualDisplayManager` already
|
||||
//! runs on glass: `Idle → Active{refs} → Lingering{until} → Idle`, plus a `Pinned` state for
|
||||
//! keep-alive-forever. The registry pairs one [`State`] with the owned backend resource; the machine
|
||||
//! only tracks the discriminant + refcount + deadline and reports what to do.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::policy::Linger;
|
||||
|
||||
/// The lifecycle state of one virtual-display slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum State {
|
||||
/// No display exists.
|
||||
#[default]
|
||||
Idle,
|
||||
/// A display exists with `refs` live sessions holding it.
|
||||
Active { refs: u32 },
|
||||
/// The last session left; the display is kept until `until`, then torn down.
|
||||
Lingering { until: Instant },
|
||||
/// The last session left; the display is kept indefinitely (keep-alive forever), until an
|
||||
/// explicit release.
|
||||
Pinned,
|
||||
}
|
||||
|
||||
/// What acquiring a slot means for the backend.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Acquire {
|
||||
/// The slot was empty — the backend must CREATE a fresh display.
|
||||
Create,
|
||||
/// The slot was already Active — another session JOINS the live display (refcount++).
|
||||
Join,
|
||||
/// The slot was kept alive (Lingering/Pinned) — REUSE the existing display (re-attach capture).
|
||||
Reuse,
|
||||
}
|
||||
|
||||
/// What releasing a hold on a slot means for the backend.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Release {
|
||||
/// Another session still holds the display — nothing to do.
|
||||
Decref,
|
||||
/// The last session left; keep the display until its deadline ([`State::Lingering`]), then tear down.
|
||||
Linger,
|
||||
/// The last session left; keep the display indefinitely ([`State::Pinned`]).
|
||||
Pin,
|
||||
/// The last session left and keep-alive is off — tear the display down now.
|
||||
Teardown,
|
||||
/// A release with no live hold (stale/duplicate) — no-op.
|
||||
Noop,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// True while a backend display resource exists (Active/Lingering/Pinned) — the registry holds
|
||||
/// the keepalive in exactly these states, and `Idle` means it has been dropped.
|
||||
pub fn has_display(self) -> bool {
|
||||
!matches!(self, State::Idle)
|
||||
}
|
||||
|
||||
/// Number of live sessions holding the display (0 unless Active).
|
||||
pub fn refs(self) -> u32 {
|
||||
match self {
|
||||
State::Active { refs } => refs,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A session acquires the slot. Transitions the state and reports whether the backend must
|
||||
/// create a fresh display, join the live one, or reuse the kept one.
|
||||
pub fn acquire(&mut self) -> Acquire {
|
||||
match *self {
|
||||
State::Idle => {
|
||||
*self = State::Active { refs: 1 };
|
||||
Acquire::Create
|
||||
}
|
||||
State::Active { refs } => {
|
||||
*self = State::Active { refs: refs + 1 };
|
||||
Acquire::Join
|
||||
}
|
||||
State::Lingering { .. } | State::Pinned => {
|
||||
*self = State::Active { refs: 1 };
|
||||
Acquire::Reuse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A session releases the slot. When the LAST session leaves, `now` + the resolved `linger`
|
||||
/// decide the kept state. Returns what the registry should do.
|
||||
pub fn release(&mut self, now: Instant, linger: Linger) -> Release {
|
||||
match *self {
|
||||
State::Active { refs } if refs > 1 => {
|
||||
*self = State::Active { refs: refs - 1 };
|
||||
Release::Decref
|
||||
}
|
||||
State::Active { .. } => match linger {
|
||||
Linger::Immediate => {
|
||||
*self = State::Idle;
|
||||
Release::Teardown
|
||||
}
|
||||
Linger::For(d) => {
|
||||
*self = State::Lingering { until: now + d };
|
||||
Release::Linger
|
||||
}
|
||||
Linger::Forever => {
|
||||
*self = State::Pinned;
|
||||
Release::Pin
|
||||
}
|
||||
},
|
||||
// Releasing a slot with no live hold is a stale/duplicate release. The registry's
|
||||
// gen-stamped leases already make a stale lease's drop a no-op before it reaches here;
|
||||
// this is the defensive backstop.
|
||||
State::Idle | State::Lingering { .. } | State::Pinned => Release::Noop,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry's linger-timer tick: a Lingering slot past its deadline goes Idle and returns
|
||||
/// `true` (the registry tears the display down). Pinned and every other state are untouched.
|
||||
pub fn poll_expiry(&mut self, now: Instant) -> bool {
|
||||
match *self {
|
||||
State::Lingering { until } if now >= until => {
|
||||
*self = State::Idle;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-release a kept display (the `/display/release` endpoint): a Lingering/Pinned slot goes
|
||||
/// Idle and the registry tears it down (`true`). An Active slot is refused (`false`) — releasing
|
||||
/// a display that still has live sessions is session management, not display management. Idle → `false`.
|
||||
pub fn force_release(&mut self) -> bool {
|
||||
match *self {
|
||||
State::Lingering { .. } | State::Pinned => {
|
||||
*self = State::Idle;
|
||||
true
|
||||
}
|
||||
State::Active { .. } | State::Idle => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn create_join_reuse_and_teardown() {
|
||||
let mut s = State::default();
|
||||
assert_eq!(s.acquire(), Acquire::Create);
|
||||
assert_eq!(s, State::Active { refs: 1 });
|
||||
// A concurrent session joins.
|
||||
assert_eq!(s.acquire(), Acquire::Join);
|
||||
assert_eq!(s.refs(), 2);
|
||||
// One leaves — still active.
|
||||
let now = Instant::now();
|
||||
assert_eq!(s.release(now, Linger::Immediate), Release::Decref);
|
||||
assert_eq!(s.refs(), 1);
|
||||
// The last leaves with keep-alive off — teardown.
|
||||
assert_eq!(s.release(now, Linger::Immediate), Release::Teardown);
|
||||
assert_eq!(s, State::Idle);
|
||||
assert!(!s.has_display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linger_then_reuse_within_window() {
|
||||
let mut s = State::default();
|
||||
let t0 = Instant::now();
|
||||
s.acquire();
|
||||
assert_eq!(
|
||||
s.release(t0, Linger::For(Duration::from_secs(10))),
|
||||
Release::Linger
|
||||
);
|
||||
assert!(s.has_display());
|
||||
// A tick before the deadline does nothing.
|
||||
assert!(!s.poll_expiry(t0 + Duration::from_secs(5)));
|
||||
// A reconnect inside the window reuses the kept display.
|
||||
assert_eq!(s.acquire(), Acquire::Reuse);
|
||||
assert_eq!(s, State::Active { refs: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linger_expires_to_teardown() {
|
||||
let mut s = State::default();
|
||||
let t0 = Instant::now();
|
||||
s.acquire();
|
||||
s.release(t0, Linger::For(Duration::from_secs(10)));
|
||||
// Past the deadline → teardown.
|
||||
assert!(s.poll_expiry(t0 + Duration::from_secs(11)));
|
||||
assert_eq!(s, State::Idle);
|
||||
// A second tick is idempotent (nothing to tear down).
|
||||
assert!(!s.poll_expiry(t0 + Duration::from_secs(12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_never_expires_but_force_releases() {
|
||||
let mut s = State::default();
|
||||
let t0 = Instant::now();
|
||||
s.acquire();
|
||||
assert_eq!(s.release(t0, Linger::Forever), Release::Pin);
|
||||
assert_eq!(s, State::Pinned);
|
||||
// No amount of ticking tears a pinned display down.
|
||||
assert!(!s.poll_expiry(t0 + Duration::from_secs(86_400)));
|
||||
assert!(s.has_display());
|
||||
// Only an explicit release does.
|
||||
assert!(s.force_release());
|
||||
assert_eq!(s, State::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_release_refuses_active() {
|
||||
let mut s = State::default();
|
||||
s.acquire();
|
||||
assert!(
|
||||
!s.force_release(),
|
||||
"an active display can't be force-released"
|
||||
);
|
||||
assert_eq!(s.refs(), 1);
|
||||
// Idle also can't.
|
||||
let mut idle = State::default();
|
||||
assert!(!idle.force_release());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_release_is_noop() {
|
||||
let mut s = State::default();
|
||||
assert_eq!(s.release(Instant::now(), Linger::Immediate), Release::Noop);
|
||||
assert_eq!(s, State::Idle);
|
||||
}
|
||||
|
||||
/// Property test (deterministic seeded walk): across an arbitrary interleaving of acquire /
|
||||
/// release / expiry-tick / force-release, the machine must never (a) leak or double-free the
|
||||
/// backend resource — `has_display()` must exactly track a shadow "resource alive" flag, with
|
||||
/// every Create preceded by no live resource and every teardown preceded by one — nor (b)
|
||||
/// underflow the refcount, nor (c) tear a display down while a session still holds it.
|
||||
#[test]
|
||||
fn property_no_leaks_no_double_free_no_underflow() {
|
||||
// Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency.
|
||||
let mut rng: u64 = 0x1234_5678_9abc_def0;
|
||||
let mut next = || {
|
||||
rng = rng
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
(rng >> 33) as u32
|
||||
};
|
||||
|
||||
let base = Instant::now();
|
||||
let mut logical_ms: u64 = 0;
|
||||
let mut s = State::default();
|
||||
// Shadow model.
|
||||
let mut resource_alive = false;
|
||||
let mut live_holds: u32 = 0;
|
||||
|
||||
for _ in 0..200_000 {
|
||||
// Advance logical time by 0..2000 ms each step so lingers cross their deadlines.
|
||||
logical_ms += (next() % 2000) as u64;
|
||||
let now = base + Duration::from_millis(logical_ms);
|
||||
|
||||
match next() % 5 {
|
||||
0 => {
|
||||
// acquire
|
||||
let before_alive = resource_alive;
|
||||
let a = s.acquire();
|
||||
match a {
|
||||
Acquire::Create => {
|
||||
assert!(!before_alive, "Create while a resource was alive")
|
||||
}
|
||||
Acquire::Join | Acquire::Reuse => {
|
||||
assert!(before_alive, "Join/Reuse with no live resource")
|
||||
}
|
||||
}
|
||||
resource_alive = true;
|
||||
live_holds += 1;
|
||||
}
|
||||
1 | 2 => {
|
||||
// release (weighted 2/5 so refs actually drain)
|
||||
let linger = match next() % 3 {
|
||||
0 => Linger::Immediate,
|
||||
1 => Linger::For(Duration::from_millis((next() % 3000) as u64 + 1)),
|
||||
_ => Linger::Forever,
|
||||
};
|
||||
let held_before = live_holds;
|
||||
let r = s.release(now, linger);
|
||||
match r {
|
||||
Release::Noop => assert_eq!(held_before, 0, "Noop only with no live hold"),
|
||||
Release::Decref => {
|
||||
assert!(held_before >= 2, "Decref must leave the display held");
|
||||
live_holds -= 1;
|
||||
}
|
||||
Release::Teardown => {
|
||||
assert_eq!(held_before, 1, "Teardown only on the last hold");
|
||||
live_holds = 0;
|
||||
resource_alive = false;
|
||||
}
|
||||
Release::Linger | Release::Pin => {
|
||||
assert_eq!(held_before, 1, "Linger/Pin only on the last hold");
|
||||
live_holds = 0;
|
||||
// resource stays alive (kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// expiry tick
|
||||
if s.poll_expiry(now) {
|
||||
assert_eq!(live_holds, 0, "expiry tore down a held display");
|
||||
resource_alive = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// force release
|
||||
if s.force_release() {
|
||||
assert_eq!(live_holds, 0, "force-release tore down a held display");
|
||||
resource_alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant after every step: the machine's own view of "a display exists" matches the
|
||||
// shadow, and the refcount matches the live-hold count.
|
||||
assert_eq!(
|
||||
s.has_display(),
|
||||
resource_alive,
|
||||
"has_display drifted from the shadow model"
|
||||
);
|
||||
assert_eq!(
|
||||
s.refs(),
|
||||
live_holds,
|
||||
"refs drifted from the live-hold count"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,290 +0,0 @@
|
||||
//! gamescope **discovery + probes** (plan §W3, carved out of the backend): finding the compositor's
|
||||
//! PipeWire node (log line first, then a scoped `pw-dump` fallback), locating its live EIS/libei
|
||||
//! socket, the version gate, and the dedicated-session game-exit check. Pure read-side plumbing — it
|
||||
//! observes gamescope, never spawns or tears it down (that stays in [`super`]).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
|
||||
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
|
||||
/// — the adapter and the inner stream — and only the advertised id is the correct capture
|
||||
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
|
||||
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
|
||||
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
|
||||
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
|
||||
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
|
||||
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
|
||||
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
|
||||
/// node stays absent across the window.
|
||||
pub(crate) fn game_session_exited(node_id: u32) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_millis(1500);
|
||||
loop {
|
||||
if gamescope_node_present(node_id) {
|
||||
return false; // OUR node is (still) present → not an exit (transient loss)
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return true; // our node stayed gone across the window → the game exited
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
|
||||
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
||||
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(id) = find_gamescope_node() {
|
||||
return Some(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_node(
|
||||
timeout: Duration,
|
||||
log: &std::path::Path,
|
||||
child_pid: u32,
|
||||
) -> Option<u32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(id) = node_from_log(log) {
|
||||
return Some(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
|
||||
// node isn't picked by mistake.
|
||||
return find_gamescope_node_scoped(Some(child_pid));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
|
||||
fn node_from_log(log: &std::path::Path) -> Option<u32> {
|
||||
let log = std::fs::read_to_string(log).ok()?;
|
||||
for line in log.lines().rev() {
|
||||
if let Some(pos) = line.find("stream available on node ID:") {
|
||||
let tail = &line[pos + "stream available on node ID:".len()..];
|
||||
let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(id) = digits.parse() {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
|
||||
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
|
||||
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
|
||||
pub(super) fn gamescope_node_present(node_id: u32) -> bool {
|
||||
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
|
||||
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
|
||||
return true;
|
||||
};
|
||||
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||
return true;
|
||||
};
|
||||
dump.as_array()
|
||||
.map(|objs| {
|
||||
objs.iter().any(|o| {
|
||||
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
|
||||
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
|
||||
})
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
||||
///
|
||||
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
|
||||
/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the
|
||||
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
|
||||
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
|
||||
pub(super) fn find_gamescope_node() -> Option<u32> {
|
||||
find_gamescope_node_scoped(None)
|
||||
}
|
||||
|
||||
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
|
||||
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
|
||||
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
|
||||
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
|
||||
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
|
||||
let out = Command::new("pw-dump").output().ok()?;
|
||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
let nodes = dump.as_array()?;
|
||||
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
|
||||
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
||||
return None;
|
||||
}
|
||||
let id = obj.get("id").and_then(|i| i.as_u64())? as u32;
|
||||
let props = obj.get("info").and_then(|i| i.get("props"));
|
||||
let name = props
|
||||
.and_then(|p| p.get("node.name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let class = props
|
||||
.and_then(|p| p.get("media.class"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// PipeWire records the owning process id as a string or an int depending on version.
|
||||
let pid = props
|
||||
.and_then(|p| p.get("application.process.id"))
|
||||
.and_then(|v| {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
.map(|n| n as u32)
|
||||
});
|
||||
Some((id, name, class, pid))
|
||||
};
|
||||
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
|
||||
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
|
||||
// per-instance log is the primary addressing (design §7 risk note).
|
||||
let in_scope = |pid: Option<u32>| -> bool {
|
||||
match scope {
|
||||
None => true,
|
||||
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
|
||||
}
|
||||
};
|
||||
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
|
||||
for obj in nodes {
|
||||
if let Some((id, name, class, pid)) = node_props(obj) {
|
||||
if class == "Video/Source"
|
||||
&& (name == "gamescope" || name.contains("gamescope"))
|
||||
&& in_scope(pid)
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
|
||||
for obj in nodes {
|
||||
if let Some((id, name, _, pid)) = node_props(obj) {
|
||||
if name == "gamescope" && in_scope(pid) {
|
||||
tracing::warn!(
|
||||
node_id = id,
|
||||
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
|
||||
);
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing
|
||||
/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file).
|
||||
///
|
||||
/// gamescope names its EIS socket `gamescope-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
|
||||
/// `gamescope-<display>` wayland socket). Stale sockets from dead sessions linger, so we don't
|
||||
/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the
|
||||
/// most recently created (the live session). Returns the bare socket *name* (the injector
|
||||
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
|
||||
pub(super) fn find_gamescope_eis_socket() -> Option<String> {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
|
||||
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// The EIS socket itself, not its `.lock` sidecar or the bare wayland socket.
|
||||
if !(name.starts_with("gamescope-") && name.ends_with("-ei")) {
|
||||
continue;
|
||||
}
|
||||
// Connectable == a live listener is behind it (a dead session's socket refuses).
|
||||
if std::os::unix::net::UnixStream::connect(entry.path()).is_err() {
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
live.push((mtime, name));
|
||||
}
|
||||
live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first
|
||||
live.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does
|
||||
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
|
||||
/// create path); just checks the binary executes.
|
||||
pub(crate) fn is_available() -> bool {
|
||||
std::process::Command::new("gamescope")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks
|
||||
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
||||
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
||||
|
||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
||||
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
||||
/// gate. Returns the parsed version when it could read one.
|
||||
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let ver = parse_version(&text)?;
|
||||
if ver < MIN_GAMESCOPE {
|
||||
tracing::warn!(
|
||||
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
||||
min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2),
|
||||
"gamescope is older than the minimum for reliable headless capture — expect a \
|
||||
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
||||
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
||||
);
|
||||
}
|
||||
Some(ver)
|
||||
}
|
||||
|
||||
/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`).
|
||||
fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
|
||||
let mut parts = token.split('.');
|
||||
let (a, b, c) = (parts.next()?, parts.next(), parts.next());
|
||||
let (Some(b), Some(c)) = (b, c) else { continue };
|
||||
if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) {
|
||||
return Some((a, b, c));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_version, MIN_GAMESCOPE};
|
||||
|
||||
#[test]
|
||||
fn parses_version_banner() {
|
||||
assert_eq!(
|
||||
parse_version("gamescope version 3.16.22"),
|
||||
Some((3, 16, 22))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_version("gamescope: version v3.15.9 (no PipeWire)"),
|
||||
Some((3, 15, 9))
|
||||
);
|
||||
assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20)));
|
||||
assert_eq!(parse_version("no version here"), None);
|
||||
assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_known_bad_versions() {
|
||||
// The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock).
|
||||
assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
||||
}
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
//! Hyprland virtual-output backend via `hyprctl` IPC + the xdg ScreenCast portal
|
||||
//! (xdg-desktop-portal-hyprland / xdph). See `design/hyprland-support.md`.
|
||||
//!
|
||||
//! Hyprland dropped wlroots in v0.42 (aquamarine backend) but kept the client-facing wlr
|
||||
//! protocols, so it shares the wlr virtual-input path with sway — but it needs its own IPC and
|
||||
//! portal, so it is a **distinct backend** from [`super::wlroots`], not a branch inside it (D1):
|
||||
//!
|
||||
//! 1. `hyprctl output create headless PF-<n>` adds a named headless output — Hyprland supports
|
||||
//! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll
|
||||
//! `hyprctl -j monitors` until the name shows up.
|
||||
//! 2. A monitor rule sets the client's exact mode. [`set_monitor_rule`] uses `hyprctl keyword
|
||||
//! monitor NAME,WxH@Hz,auto,1` (the hyprlang path — the default config manager on every current
|
||||
//! release, ≥0.55 included) and falls back to the Lua `hyprctl eval 'hl.monitor{…}'` only for a
|
||||
//! user on the opt-in Lua config manager, confirming the output actually adopted the mode (D5).
|
||||
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
|
||||
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
|
||||
//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
|
||||
//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
|
||||
//! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
|
||||
//! `hyprctl output remove NAME`.
|
||||
//!
|
||||
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
|
||||
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
|
||||
//! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with
|
||||
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
|
||||
//!
|
||||
//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
|
||||
//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
|
||||
//! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
|
||||
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
|
||||
//! surfaces that as a clear error instead of streaming a 0×0 output.
|
||||
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Once};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Per-session file the xdph custom picker reads the selected output from. We write
|
||||
/// `screen:<NAME>\n` here right before the portal handshake selects sources. Lives under
|
||||
/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could
|
||||
/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
|
||||
/// wlroots chooser file.
|
||||
fn selection_file() -> String {
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{dir}/punktfunk-xdph-output")
|
||||
}
|
||||
|
||||
/// The installed custom-picker shim: a tiny script that cats [`selection_file`]. xdph runs
|
||||
/// `custom_picker_binary` and reads one selection line from its stdout; an empty read (no session
|
||||
/// has written the file) leaves xdph to its interactive picker — the graceful fallback.
|
||||
fn picker_shim_path() -> String {
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{dir}/punktfunk-xdph-picker.sh")
|
||||
}
|
||||
|
||||
/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
|
||||
/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
|
||||
/// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
|
||||
/// "strange output" and falls back to the interactive picker. So a monitor selection is
|
||||
/// `[SELECTION]screen:<name>`.
|
||||
fn picker_selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]screen:{name}\n")
|
||||
}
|
||||
|
||||
/// The managed xdph config: point the screencopy custom picker at our shim so headless output
|
||||
/// selection needs no GUI. xdph reads its config at startup, so a change restarts it (see
|
||||
/// [`ensure_xdph_config`]). The *selection* is the per-session file, not this static config.
|
||||
fn xdph_config() -> String {
|
||||
format!(
|
||||
"# managed by punktfunk (vdisplay/hyprland.rs) — headless per-session output selection.\n\
|
||||
screencopy {{\n\
|
||||
custom_picker_binary = {}\n\
|
||||
}}\n",
|
||||
picker_shim_path()
|
||||
)
|
||||
}
|
||||
|
||||
/// Monotonic per-process counter for headless output names (`PF-1`, `PF-2`, …). Named outputs kill
|
||||
/// the before/after diff race sway needs (D6).
|
||||
static OUTPUT_SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn next_output_name() -> String {
|
||||
format!("PF-{}", OUTPUT_SEQ.fetch_add(1, Ordering::Relaxed) + 1)
|
||||
}
|
||||
|
||||
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
|
||||
/// named headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct HyprlandDisplay;
|
||||
|
||||
impl HyprlandDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(HyprlandDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` (inherited from the session) **or** a discoverable instance socket
|
||||
/// under `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env
|
||||
/// import, unlike sway's `SWAYSOCK`; the signature is then exported by `apply_session_env`). Cheap,
|
||||
/// side-effect-free — safe on the enumeration path.
|
||||
pub fn is_available() -> bool {
|
||||
if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
|
||||
return true;
|
||||
}
|
||||
let dir = match std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
Some(d) => std::path::PathBuf::from(d).join("hypr"),
|
||||
None => return false,
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.any(|e| e.path().join(".socket.sock").exists())
|
||||
}
|
||||
|
||||
/// Pre-flight for the Hyprland backend: `hyprctl` must reach the compositor (a clear error now
|
||||
/// beats a create-time failure), and if the permission system is enforcing, warn about the silent
|
||||
/// black-frame / dropped-input failure mode.
|
||||
pub fn probe() -> Result<()> {
|
||||
hyprctl(&["-j", "version"]).context(
|
||||
"hyprctl not reachable — is Hyprland running and HYPRLAND_INSTANCE_SIGNATURE set? (the \
|
||||
host must run inside, or be able to reach, the Hyprland session)",
|
||||
)?;
|
||||
if let Some((maj, min, pat)) = hyprland_version() {
|
||||
tracing::info!(version = %format!("{maj}.{min}.{pat}"), "Hyprland backend ready");
|
||||
}
|
||||
warn_if_permissions_enforced();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl VirtualDisplay for HyprlandDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"hyprland"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Log the permission-system caveat once per process (silent black frames otherwise).
|
||||
preflight_once();
|
||||
|
||||
let name = next_output_name();
|
||||
hyprctl_dispatch(&["output", "create", "headless", &name]).with_context(|| {
|
||||
format!("hyprctl output create headless {name} (is hyprctl reachable?)")
|
||||
})?;
|
||||
// Own the output from here on so any later error (or drop) removes it.
|
||||
let output = OutputGuard(name.clone());
|
||||
wait_monitor_ready(&name, Duration::from_secs(5))
|
||||
.with_context(|| format!("waiting for headless output {name} to appear"))?;
|
||||
|
||||
// The client's exact mode (also the frame clock — a headless output is timer-paced from it).
|
||||
set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?;
|
||||
|
||||
// Steer xdph's custom picker at our new output, then run the portal handshake on its own
|
||||
// thread (it parks to keep the cast alive, like the other backends).
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(&name))
|
||||
.with_context(|| format!("write {sel}"))?;
|
||||
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
"hyprland headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(Keepalive {
|
||||
_stop: StopGuard(stop),
|
||||
_output: output,
|
||||
}),
|
||||
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
|
||||
// portal fd can't be re-opened per attach, so the registry passes it through on
|
||||
// `remote_fd.is_some()` — same as wlroots.
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then
|
||||
/// remove the output (fields drop in declaration order).
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then
|
||||
/// tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the created headless output; dropping it removes it from Hyprland.
|
||||
struct OutputGuard(String);
|
||||
|
||||
impl Drop for OutputGuard {
|
||||
fn drop(&mut self) {
|
||||
match hyprctl_dispatch(&["output", "remove", &self.0]) {
|
||||
Ok(_) => tracing::info!(output = %self.0, "hyprland headless output removed"),
|
||||
Err(e) => {
|
||||
tracing::warn!(output = %self.0, error = %format!("{e:#}"), "output remove failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `hyprctl <args>`, returning stdout. `hyprctl` reads `HYPRLAND_INSTANCE_SIGNATURE` from the
|
||||
/// env (exported by `apply_session_env`) to reach the right instance socket. It exits non-zero on a
|
||||
/// hard failure, but for dispatch commands it can print an error with status 0 — see
|
||||
/// [`hyprctl_dispatch`].
|
||||
fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
let out = Command::new("hyprctl")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("run hyprctl (is Hyprland installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"hyprctl {:?} failed: {}{}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stdout).trim(),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by
|
||||
/// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to
|
||||
/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the
|
||||
/// two config eras apart).
|
||||
fn hyprctl_dispatch(args: &[&str]) -> Result<()> {
|
||||
let out = hyprctl(args)?;
|
||||
let t = out.trim();
|
||||
let lc = t.to_ascii_lowercase();
|
||||
if lc.contains("invalid")
|
||||
|| lc.contains("not found")
|
||||
|| lc.contains("couldn't")
|
||||
|| lc.contains("could not")
|
||||
|| lc.contains("unknown")
|
||||
|| lc.contains("no such")
|
||||
|| lc.contains("error")
|
||||
// `hyprctl eval` on a hyprlang (non-Lua) config: "eval is only supported with the lua
|
||||
// config manager" — a rejection hyprctl reports with exit 0 and no other marker.
|
||||
|| lc.contains("only supported")
|
||||
|| lc.contains("not supported")
|
||||
{
|
||||
bail!("hyprctl {:?} rejected: {t}", args);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait until the named headless output shows up in `hyprctl -j monitors` (it appears near-instantly
|
||||
/// in practice; poll briefly to be safe).
|
||||
fn wait_monitor_ready(name: &str, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if monitor_exists(name)? {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("output create succeeded but monitor {name} never appeared");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a monitor named `name` present in `hyprctl -j monitors` (JSON)?
|
||||
fn monitor_exists(name: &str) -> Result<bool> {
|
||||
let out = hyprctl(&["-j", "monitors"])?;
|
||||
let monitors: serde_json::Value =
|
||||
serde_json::from_str(&out).context("parse hyprctl -j monitors")?;
|
||||
Ok(monitors
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.any(|m| m.get("name").and_then(|n| n.as_str()) == Some(name))
|
||||
})
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Set the client's exact mode on `name`, supporting both config eras (D5). Encapsulates the whole
|
||||
/// era split (D5). `hyprctl keyword monitor NAME,WxH@Hz,auto,1` is the hyprlang path — the default
|
||||
/// config manager on **every** current release, including ≥0.55 (verified on 0.55.4: version does
|
||||
/// NOT imply the Lua era — a stock 0.55.4 rejects `eval` with "only supported with the lua config
|
||||
/// manager"). So we try `keyword` first and fall back to the Lua `hyprctl eval 'hl.monitor{…}'` only
|
||||
/// for a user who opted into the Lua config manager (where `keyword` is gone). Either way we confirm
|
||||
/// the output actually adopted the mode — some forms print `ok` for a command they ignored.
|
||||
///
|
||||
/// A headless output starts at 0×0 and only gets a framebuffer once a mode commits; if neither form
|
||||
/// makes it adopt a usable (non-zero) size the compositor couldn't back the mode (a headless GBM /
|
||||
/// dmabuf allocation failure — Sunshine#4197, seen on some NVIDIA setups), which we surface clearly
|
||||
/// rather than streaming a 0×0 corpse.
|
||||
fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
|
||||
let hz = mode.refresh_hz.max(1);
|
||||
let spec = format!("{name},{}x{}@{hz},auto,1", mode.width, mode.height);
|
||||
let lua = format!(
|
||||
"hl.monitor{{ output = \"{name}\", mode = \"{}x{}@{hz}\", position = \"auto\", scale = 1 }}",
|
||||
mode.width, mode.height
|
||||
);
|
||||
let keyword: Vec<&str> = vec!["keyword", "monitor", &spec];
|
||||
let eval: Vec<&str> = vec!["eval", &lua];
|
||||
for a in [&keyword, &eval] {
|
||||
// A wrong-era command errors (`keyword` gone under Lua, or `eval` under hyprlang) — skip to
|
||||
// the other form. A command that's accepted then has up to the timeout to take effect.
|
||||
if hyprctl_dispatch(a).is_err() {
|
||||
continue;
|
||||
}
|
||||
if wait_exact_mode(name, mode, Duration::from_millis(1500)) {
|
||||
tracing::debug!(output = %name, cmd = ?a, w = mode.width, h = mode.height, "monitor adopted the requested mode");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Neither form produced the exact mode. Distinguish "usable but different size" (proceed with a
|
||||
// warning — a working stream beats none) from "0×0 / gone" (the output has no framebuffer at all).
|
||||
match monitor_size(name)? {
|
||||
Some((w, h)) if w > 0 && h > 0 => {
|
||||
tracing::warn!(
|
||||
output = %name,
|
||||
requested = %format!("{}x{}", mode.width, mode.height),
|
||||
got = %format!("{w}x{h}"),
|
||||
"Hyprland did not adopt the exact requested mode — streaming at the output's current size"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => bail!(
|
||||
"headless output {name} never got a framebuffer (stayed 0x0) after the monitor rule for \
|
||||
{}x{}@{hz} — the compositor could not back the mode, likely a headless GBM/dmabuf \
|
||||
allocation failure (GPU driver; cf. Sunshine#4197). Check the Hyprland log.",
|
||||
mode.width,
|
||||
mode.height
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll until monitor `name` reports exactly `mode`'s width×height (the rule applies asynchronously),
|
||||
/// up to `timeout`. Returns `false` on timeout.
|
||||
fn wait_exact_mode(name: &str, mode: Mode, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if matches!(monitor_size(name), Ok(Some((w, h))) if w == mode.width as u64 && h == mode.height as u64)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitor `name`'s current `(width, height)` from `hyprctl -j monitors all` (includes a disabled
|
||||
/// output), or `None` if it isn't present. A freshly-created headless output reports `0×0` until a
|
||||
/// mode commits.
|
||||
fn monitor_size(name: &str) -> Result<Option<(u64, u64)>> {
|
||||
let out = hyprctl(&["-j", "monitors", "all"])?;
|
||||
let monitors: serde_json::Value =
|
||||
serde_json::from_str(&out).context("parse hyprctl -j monitors")?;
|
||||
let Some(arr) = monitors.as_array() else {
|
||||
return Ok(None);
|
||||
};
|
||||
for m in arr {
|
||||
if m.get("name").and_then(|n| n.as_str()) == Some(name) {
|
||||
let w = m.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let h = m.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
return Ok(Some((w, h)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.4`),
|
||||
/// for a diagnostic log — the mode-rule path is version-independent (see [`set_monitor_rule`]).
|
||||
fn hyprland_version() -> Option<(u16, u16, u16)> {
|
||||
let out = hyprctl(&["-j", "version"]).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&out).ok()?;
|
||||
parse_version_tag(json.get("tag").and_then(|t| t.as_str())?)
|
||||
}
|
||||
|
||||
/// Parse a Hyprland `tag` (`v0.55.4`, or a dev `v0.41.2-13-gabcdef`) to `(major, minor, patch)`.
|
||||
fn parse_version_tag(tag: &str) -> Option<(u16, u16, u16)> {
|
||||
let t = tag.trim().trim_start_matches(['v', 'V']);
|
||||
let mut it = t.split(['.', '-', '_', '+']);
|
||||
let major = it.next()?.parse().ok()?;
|
||||
let minor = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let patch = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// Log the permission-system caveat at most once per process: with
|
||||
/// `ecosystem.enforce_permissions = true` (0.49+, off by default), direct screencopy/virtual-input
|
||||
/// clients can be denied — and denial is **silent black frames / dropped input**, not an error.
|
||||
fn preflight_once() {
|
||||
static WARNED: Once = Once::new();
|
||||
WARNED.call_once(warn_if_permissions_enforced);
|
||||
}
|
||||
|
||||
fn warn_if_permissions_enforced() {
|
||||
let Ok(out) = hyprctl(&["-j", "getoption", "ecosystem:enforce_permissions"]) else {
|
||||
return;
|
||||
};
|
||||
let on = serde_json::from_str::<serde_json::Value>(&out)
|
||||
.ok()
|
||||
.and_then(|j| j.get("int").and_then(|v| v.as_i64()))
|
||||
.is_some_and(|v| v != 0);
|
||||
if on {
|
||||
tracing::warn!(
|
||||
"Hyprland ecosystem.enforce_permissions is ON — screencopy/virtual-input may be denied \
|
||||
as SILENT black frames / dropped input. Grant the host with hl.permission rules \
|
||||
(screencopy + virtual pointer/keyboard) — see docs/hyprland."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure xdph uses our custom picker: install the shim (once) and write the managed config,
|
||||
/// restarting xdph if the config changed (it reads config only at startup). Mirrors the wlroots
|
||||
/// `ensure_xdpw_config` pattern.
|
||||
fn ensure_xdph_config() -> Result<()> {
|
||||
// 1. Install the picker shim (idempotent — content is fixed).
|
||||
let shim = picker_shim_path();
|
||||
let sel = selection_file();
|
||||
let shim_body = format!("#!/bin/sh\nexec cat \"{sel}\" 2>/dev/null\n");
|
||||
if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) {
|
||||
// already installed
|
||||
} else {
|
||||
std::fs::write(&shim, &shim_body).with_context(|| format!("write {shim}"))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700))
|
||||
.with_context(|| format!("chmod {shim}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write the managed xdph config and restart xdph on change.
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
|
||||
let dir = base.join("hypr");
|
||||
let path = dir.join("xdph.conf");
|
||||
let cfg = xdph_config();
|
||||
if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
|
||||
std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?;
|
||||
tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-hyprland config");
|
||||
let _ = Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"try-restart",
|
||||
"xdg-desktop-portal-hyprland.service",
|
||||
])
|
||||
.status();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ScreenCast portal handshake — the xdg ScreenCast portal is backend-neutral (served here by
|
||||
/// xdph), so this mirrors the wlroots portal thread: it reports the fd + node id and parks until
|
||||
/// stopped (the zbus connection is the cast's lifetime). xdph answers source selection via our
|
||||
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
|
||||
/// self-owned per D1; unify if they ever diverge no further.)
|
||||
fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<AtomicBool>) {
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast
|
||||
// is torn down when the connection drops.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_tag_parses_release_and_dev_builds() {
|
||||
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
|
||||
assert_eq!(parse_version_tag("0.41.2"), Some((0, 41, 2)));
|
||||
// Dev builds tack the commit distance + hash on with a dash.
|
||||
assert_eq!(parse_version_tag("v0.41.2-13-gabcdef"), Some((0, 41, 2)));
|
||||
// Missing patch defaults to 0; garbage is rejected.
|
||||
assert_eq!(parse_version_tag("v1.0"), Some((1, 0, 0)));
|
||||
assert_eq!(parse_version_tag("wat"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_names_are_unique_and_prefixed() {
|
||||
let a = next_output_name();
|
||||
let b = next_output_name();
|
||||
assert!(a.starts_with("PF-") && b.starts_with("PF-"));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_line_carries_the_selection_marker() {
|
||||
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
|
||||
}
|
||||
}
|
||||
@@ -1,739 +0,0 @@
|
||||
//! KWin virtual-output backend via the privileged `zkde_screencast_unstable_v1` Wayland
|
||||
//! protocol (the mechanism KRdp / krfb-virtualmonitor use).
|
||||
//!
|
||||
//! `stream_virtual_output(name, width, height, scale, pointer)` asks KWin to create a new output
|
||||
//! sized to exactly `width`x`height`, rendered natively (no scaling), and hands back a PipeWire
|
||||
//! node for it. The node lives on the user's default PipeWire daemon, so [`VirtualOutput::remote_fd`]
|
||||
//! is `None` and capture connects to that daemon directly.
|
||||
//!
|
||||
//! Requirements: KWin must expose the privileged `zkde_screencast` global. It is a *restricted*
|
||||
//! protocol — KWin advertises it only to a client whose installed `.desktop` lists it under
|
||||
//! `X-KDE-Wayland-Interfaces` (KWin maps the connecting client to a `.desktop` by resolving
|
||||
//! `/proc/<pid>/exe` against `Exec=`, then caches the grant per-executable for the session's life).
|
||||
//! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship
|
||||
//! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`,
|
||||
//! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first
|
||||
//! connects. The headless test path instead exposes it to bare clients via
|
||||
//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
|
||||
//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin
|
||||
//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with
|
||||
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
|
||||
//! the KWin session's environment.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
// Generate the client bindings for the vendored protocol XML inline (no build.rs). Path is
|
||||
// relative to CARGO_MANIFEST_DIR. See wayland-rs' "implementing a custom protocol" docs.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod zkde {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/zkde-screencast-unstable-v1.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/zkde-screencast-unstable-v1.xml");
|
||||
}
|
||||
|
||||
use zkde::zkde_screencast_stream_unstable_v1::{
|
||||
Event as StreamEvent, ZkdeScreencastStreamUnstableV1 as ScreencastStream,
|
||||
};
|
||||
use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
|
||||
|
||||
/// `pointer` attachment mode (the protocol enum): render the cursor into the stream so the
|
||||
/// remote sees it move with injected input.
|
||||
const POINTER_EMBEDDED: u32 = 2;
|
||||
|
||||
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
|
||||
const VOUT_NAME: &str = "punktfunk";
|
||||
|
||||
/// Highest interface version we drive. KWin currently advertises 5; we rely on the `created`
|
||||
/// 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. 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]>,
|
||||
/// The identity slot the last [`create`](VirtualDisplay::create) resolved (the per-client id, or
|
||||
/// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key
|
||||
/// the group arrangement + `/display/state` slot to the same id this backend named the output with.
|
||||
last_slot: Option<u32>,
|
||||
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so
|
||||
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`.
|
||||
last_name: Option<String>,
|
||||
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
|
||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
|
||||
/// A backstop [`Drop`] runs it if the registry never took it (so a physical is never left dark).
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for KwinDisplay {
|
||||
fn drop(&mut self) {
|
||||
// Backstop only: the registry takes the restore right after `create` (moving it into the group),
|
||||
// so this is normally `None`. If some path skipped the take, re-enable here so a physical is
|
||||
// never stranded dark.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KwinDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(KwinDisplay::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for KwinDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"kwin"
|
||||
}
|
||||
|
||||
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
|
||||
self.client_fp = fingerprint;
|
||||
}
|
||||
|
||||
fn last_identity_slot(&self) -> Option<u32> {
|
||||
self.last_slot
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn apply_position(&mut self, x: i32, y: i32) {
|
||||
let Some(name) = self.last_name.clone() else {
|
||||
return;
|
||||
};
|
||||
let output = format!("Virtual-{name}");
|
||||
// kscreen-doctor position syntax: `output.<name>.position.<x>,<y>`.
|
||||
let ok = std::process::Command::new("kscreen-doctor")
|
||||
.arg(format!("output.{output}.position.{x},{y}"))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
tracing::info!(output, x, y, "KWin: placed output in the desktop layout");
|
||||
} else {
|
||||
tracing::warn!(output, x, y, "KWin: output position apply failed");
|
||||
}
|
||||
}
|
||||
|
||||
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 slot = crate::vdisplay::identity::resolve_slot(
|
||||
self.client_fp,
|
||||
(mode.width, mode.height),
|
||||
crate::vdisplay::policy::Identity::Shared,
|
||||
);
|
||||
self.last_slot = slot; // reported to the registry for the group arrangement + state slot
|
||||
let name = match slot {
|
||||
Some(id) => format!("{VOUT_NAME}-{id}"),
|
||||
None => VOUT_NAME.to_string(),
|
||||
};
|
||||
self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
|
||||
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, name_thread, setup_tx, stop_thread))
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("KWin virtual output failed: {e}"),
|
||||
Err(_) => bail!("timed out creating the KWin virtual output"),
|
||||
};
|
||||
tracing::info!(node_id, width, height, "KWin virtual output ready");
|
||||
// KWin creates virtual outputs at a hardcoded 60 Hz and `stream_virtual_output` has no
|
||||
// refresh argument, so above 60 Hz we install + select a custom mode (supported on virtual
|
||||
// outputs since KWin 6.6) before capture connects PipeWire, so the stream negotiates at the
|
||||
// higher rate. First cut shells out to kscreen-doctor; the in-process
|
||||
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back and
|
||||
// returns what KWin *actually* achieved so the encoder paces to the real source rate (a
|
||||
// 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, &name)
|
||||
} else {
|
||||
mode.refresh_hz
|
||||
};
|
||||
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
|
||||
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
|
||||
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
|
||||
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
||||
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
|
||||
use crate::vdisplay::policy::Topology;
|
||||
let disabled = match crate::vdisplay::effective_topology() {
|
||||
Topology::Exclusive => apply_virtual_primary(&name),
|
||||
Topology::Primary => {
|
||||
apply_virtual_primary_only(&name);
|
||||
Vec::new() // nothing disabled → nothing to restore
|
||||
}
|
||||
Topology::Extend | Topology::Auto => Vec::new(),
|
||||
};
|
||||
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
|
||||
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
|
||||
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
|
||||
// into the display group and runs once, when the group's LAST member is torn down (ordered before
|
||||
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
let disabled = disabled.clone();
|
||||
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
Some((mode.width, mode.height, achieved_hz)),
|
||||
Box::new(StopGuard { stop }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
|
||||
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
|
||||
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
||||
fn reenable_outputs(outputs: &[(String, String)]) {
|
||||
if outputs.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Enable FIRST, as a standalone apply — a bare `output.X.enable` always succeeds, so a physical
|
||||
// can never be left DARK. (Batching a possibly-stale `mode` arg into the same invocation risks
|
||||
// kscreen-doctor rejecting the whole config and leaving the output disabled.)
|
||||
let enable_args: Vec<String> = outputs
|
||||
.iter()
|
||||
.map(|(name, _)| format!("output.{name}.enable"))
|
||||
.collect();
|
||||
let _ = std::process::Command::new("kscreen-doctor")
|
||||
.args(&enable_args)
|
||||
.status();
|
||||
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
|
||||
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
|
||||
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
|
||||
let mode_args: Vec<String> = outputs
|
||||
.iter()
|
||||
.filter(|(_, mode)| !mode.is_empty())
|
||||
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
|
||||
.collect();
|
||||
if !mode_args.is_empty() {
|
||||
let _ = std::process::Command::new("kscreen-doctor")
|
||||
.args(&mode_args)
|
||||
.status();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
|
||||
}
|
||||
|
||||
/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by
|
||||
/// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-<VOUT_NAME>`,
|
||||
/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually
|
||||
/// 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, name: &str) -> u32 {
|
||||
let output = format!("Virtual-{name}");
|
||||
let mhz = hz.saturating_mul(1000);
|
||||
let run = |arg: String| {
|
||||
std::process::Command::new("kscreen-doctor")
|
||||
.arg(arg)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
// Add the custom mode (a fresh output has none), then select it.
|
||||
let _ = run(format!(
|
||||
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
||||
));
|
||||
let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}"));
|
||||
match read_active_refresh(&output) {
|
||||
Some(achieved) if achieved >= hz => {
|
||||
tracing::info!(
|
||||
output,
|
||||
requested = hz,
|
||||
achieved,
|
||||
"KWin virtual output: custom refresh applied"
|
||||
);
|
||||
achieved
|
||||
}
|
||||
Some(achieved) => {
|
||||
tracing::warn!(
|
||||
output,
|
||||
requested = hz,
|
||||
achieved,
|
||||
applied,
|
||||
"KWin virtual output refresh below requested — pacing the encoder to the achieved \
|
||||
rate (custom-mode install rejected? is kscreen-doctor up to date?)"
|
||||
);
|
||||
achieved.max(1)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
output,
|
||||
requested = hz,
|
||||
applied,
|
||||
"could not read back KWin virtual output refresh — assuming 60 Hz (is \
|
||||
kscreen-doctor installed?)"
|
||||
);
|
||||
60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the active refresh (Hz, rounded) of `output` from `kscreen-doctor -j`. `None` if the
|
||||
/// tool, the output, or its current mode can't be found. Mode/output ids come through as either
|
||||
/// JSON strings or numbers depending on the KWin version, so both are accepted.
|
||||
fn read_active_refresh(output: &str) -> Option<u32> {
|
||||
let out = std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
.ok()?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
let as_id = |v: &serde_json::Value| -> Option<String> {
|
||||
v.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||
};
|
||||
let o = doc
|
||||
.get("outputs")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|o| o.get("name").and_then(|n| n.as_str()) == Some(output))?;
|
||||
let current = o.get("currentModeId").and_then(as_id)?;
|
||||
let mode = o
|
||||
.get("modes")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?;
|
||||
let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?;
|
||||
Some(hz.round() as u32)
|
||||
}
|
||||
|
||||
/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
|
||||
/// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-<id>`. Group membership (§6.1) is
|
||||
/// recognised by this prefix, so we never have to thread the live set through the backend.
|
||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||
|
||||
/// The current mode of an output as a kscreen-doctor mode setter, from its `-j` entry — preferring
|
||||
/// the human `WxH@Hz` form (survives a mode-id re-enumeration across disable→enable) and falling back
|
||||
/// to the raw `currentModeId`. `None` if the current mode can't be resolved.
|
||||
fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
|
||||
let as_id = |v: &serde_json::Value| -> Option<String> {
|
||||
v.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||
};
|
||||
let current = o.get("currentModeId").and_then(&as_id)?;
|
||||
let mode = o
|
||||
.get("modes")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|m| m.get("id").and_then(&as_id).as_deref() == Some(current.as_str()))?;
|
||||
let human = (|| {
|
||||
let size = mode.get("size")?;
|
||||
let w = size.get("width").and_then(|v| v.as_u64())?;
|
||||
let h = size.get("height").and_then(|v| v.as_u64())?;
|
||||
let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?.round() as u64;
|
||||
Some(format!("{w}x{h}@{hz}"))
|
||||
})();
|
||||
Some(human.unwrap_or(current))
|
||||
}
|
||||
|
||||
/// Currently-ENABLED outputs that are **not managed by us** — the headless session's bootstrap
|
||||
/// output(s) + any physical monitor, i.e. exactly what `exclusive` must disable — EACH PAIRED WITH ITS
|
||||
/// CURRENT MODE (`WxH@Hz`, empty if unresolved) so teardown can put it back at that exact refresh (a
|
||||
/// bare re-enable drops a 120 Hz panel to KWin's default ~60 Hz).
|
||||
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
|
||||
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
|
||||
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]).
|
||||
fn other_enabled_outputs() -> Vec<(String, String)> {
|
||||
let out = match std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let doc: serde_json::Value = match serde_json::from_slice(&out.stdout) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
doc.get("outputs")
|
||||
.and_then(|o| o.as_array())
|
||||
.map(|outs| {
|
||||
outs.iter()
|
||||
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
|
||||
.filter_map(|o| {
|
||||
let name = o.get("name").and_then(|n| n.as_str())?;
|
||||
(!name.starts_with(MANAGED_PREFIX)).then(|| {
|
||||
(
|
||||
name.to_string(),
|
||||
output_current_mode_spec(o).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// True if any managed group member (the [`MANAGED_PREFIX`] family) is ALREADY the KWin primary —
|
||||
/// first-slot-wins support (§6.1) so a later exclusive session doesn't steal primary from the group's
|
||||
/// first member. Best-effort: if kscreen reports no primary flag we treat it as "none" (the session
|
||||
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
|
||||
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
|
||||
fn a_managed_output_is_primary() -> bool {
|
||||
let Ok(out) = std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||
return false;
|
||||
};
|
||||
doc.get("outputs")
|
||||
.and_then(|o| o.as_array())
|
||||
.map(|outs| {
|
||||
outs.iter().any(|o| {
|
||||
let managed = o
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
|
||||
let primary = o.get("primary").and_then(|p| p.as_bool()).unwrap_or(false)
|
||||
|| o.get("priority").and_then(|p| p.as_u64()) == Some(1);
|
||||
managed && primary
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes
|
||||
/// the sole 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(name: &str) -> Vec<(String, String)> {
|
||||
let ours = format!("Virtual-{name}");
|
||||
let kscreen = |args: &[String]| {
|
||||
std::process::Command::new("kscreen-doctor")
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
|
||||
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
|
||||
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
|
||||
// bootstrap on its own; the belt-and-suspenders disable below covers the rest.
|
||||
if !a_managed_output_is_primary() {
|
||||
if !kscreen(&[format!("output.{ours}.primary")]) {
|
||||
tracing::warn!(
|
||||
"KWin: could not set the virtual output primary; client may see only the wallpaper"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
// Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so
|
||||
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
|
||||
// Each is captured WITH its current mode so teardown restores its real refresh, not KWin's default.
|
||||
let others = other_enabled_outputs();
|
||||
if !others.is_empty() {
|
||||
let args: Vec<String> = others
|
||||
.iter()
|
||||
.map(|(o, _mode)| format!("output.{o}.disable"))
|
||||
.collect();
|
||||
let _ = kscreen(&args);
|
||||
}
|
||||
tracing::info!(also_disabled = ?others, "KWin: streamed output set as the sole desktop");
|
||||
others
|
||||
}
|
||||
|
||||
/// **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(name: &str) {
|
||||
let ours = format!("Virtual-{name}");
|
||||
let ok = std::process::Command::new("kscreen-doctor")
|
||||
.arg(format!("output.{ours}.primary"))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
|
||||
} else {
|
||||
tracing::warn!("KWin: could not set the virtual output primary");
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
||||
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
|
||||
/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which
|
||||
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
screencast: Option<Screencast>,
|
||||
node_id: Option<u32>,
|
||||
failed: Option<String>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
if interface == Screencast::interface().name {
|
||||
let v = version.min(MAX_VERSION);
|
||||
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events.
|
||||
impl Dispatch<Screencast, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &Screencast,
|
||||
_: zkde::zkde_screencast_unstable_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ScreencastStream, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ScreencastStream,
|
||||
event: StreamEvent,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
StreamEvent::Created { node } => state.node_id = Some(node),
|
||||
StreamEvent::Failed { error } => state.failed = Some(error),
|
||||
StreamEvent::Closed => state.closed = true,
|
||||
// `serial` (v6) — we use the node id from `created`, so ignore.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker thread: create a `width`x`height` virtual output on KWin, send its PipeWire node id
|
||||
/// back over `setup_tx`, then keep the Wayland connection alive (so the output isn't destroyed)
|
||||
/// until `stop` is set. Mirrors the portal thread's "park to keep the session alive".
|
||||
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, &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:#}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
|
||||
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
|
||||
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
|
||||
/// this to gate on the compositor being *ready* (not merely the socket existing) instead of
|
||||
/// racing it with a blind sleep. `Ok(())` = ready; `Err` = not ready / no global yet.
|
||||
pub fn probe() -> Result<()> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut state = State::default();
|
||||
queue.roundtrip(&mut state).context("registry roundtrip")?;
|
||||
if state.screencast.is_none() {
|
||||
bail!(
|
||||
"KWin is up but does not expose zkde_screencast_unstable_v1 to this client — KWin gates \
|
||||
it on the host's .desktop X-KDE-Wayland-Interfaces (install \
|
||||
io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \
|
||||
re-reads it — the grant is cached per-exe on first connect), or set \
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// KWin is usable iff we're inside a KWin session exposing `zkde_screencast` — exactly what
|
||||
/// [`probe`] checks, surfaced as a bool for compositor enumeration.
|
||||
pub fn is_available() -> bool {
|
||||
probe().is_ok()
|
||||
}
|
||||
|
||||
fn run(
|
||||
width: u32,
|
||||
height: u32,
|
||||
name: &str,
|
||||
setup_tx: &Sender<Result<u32, String>>,
|
||||
stop: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
|
||||
let mut state = State::default();
|
||||
queue.roundtrip(&mut state).context("registry roundtrip")?;
|
||||
|
||||
let screencast = state.screencast.clone().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create the virtual output sized to the client, cursor composited into the stream.
|
||||
let stream = screencast.stream_virtual_output(
|
||||
name.to_string(),
|
||||
width as i32,
|
||||
height as i32,
|
||||
1.0, // scale (logical == physical)
|
||||
POINTER_EMBEDDED,
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
"KWin: requested virtual output; awaiting PipeWire node"
|
||||
);
|
||||
|
||||
// Pump events until KWin reports the node id (or an error).
|
||||
let node_id = loop {
|
||||
queue
|
||||
.blocking_dispatch(&mut state)
|
||||
.context("wayland dispatch (awaiting created)")?;
|
||||
if let Some(node) = state.node_id {
|
||||
break node;
|
||||
}
|
||||
if let Some(e) = state.failed.take() {
|
||||
bail!("stream_virtual_output failed: {e}");
|
||||
}
|
||||
if state.closed {
|
||||
bail!("KWin closed the stream before it was created");
|
||||
}
|
||||
};
|
||||
setup_tx
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Keep the connection (and thus the virtual output) alive until told to stop, observing
|
||||
// `closed`. blocking_dispatch can't be interrupted, so poll the connection fd with a short
|
||||
// timeout so `stop` is honored within ~200 ms.
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
queue
|
||||
.dispatch_pending(&mut state)
|
||||
.context("dispatch_pending")?;
|
||||
if state.closed {
|
||||
tracing::warn!(output = %name, node_id, "KWin closed the virtual-output stream");
|
||||
break;
|
||||
}
|
||||
conn.flush().context("wayland flush")?;
|
||||
let Some(guard) = conn.prepare_read() else {
|
||||
continue; // events already queued — loop dispatches them
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: conn.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: `&mut pfd` points at a single live, fully-initialized `libc::pollfd` on the stack, and
|
||||
// the count `1` matches that one-element array, so `poll` reads `fd`/`events` and writes `revents`
|
||||
// strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because `conn` (and the
|
||||
// `prepare_read` guard) are alive across the call. `poll` blocks up to 200 ms and writes only
|
||||
// `revents`; `pfd` outlives the synchronous call and aliases nothing (a fresh local).
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, 200) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
} // else: timeout or signal — drop the guard, re-check `stop`
|
||||
}
|
||||
|
||||
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
|
||||
stream.close();
|
||||
let _ = conn.flush();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MANAGED_PREFIX;
|
||||
|
||||
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
|
||||
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
|
||||
/// (the Stage-3 naming would otherwise make a 2nd exclusive session black out the 1st).
|
||||
#[test]
|
||||
fn exclusive_disables_only_non_managed() {
|
||||
let enabled = [
|
||||
"Virtual-punktfunk", // base name (shared identity)
|
||||
"Virtual-punktfunk-1", // client A's per-slot output
|
||||
"Virtual-punktfunk-7", // client B's per-slot output
|
||||
"eDP-1", // a physical panel
|
||||
];
|
||||
let to_disable: Vec<&str> = enabled
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|n| !n.starts_with(MANAGED_PREFIX))
|
||||
.collect();
|
||||
assert_eq!(to_disable, vec!["eDP-1"]);
|
||||
}
|
||||
}
|
||||
@@ -1,978 +0,0 @@
|
||||
//! GNOME/Mutter virtual-display backend via Mutter's *direct* D-Bus APIs (the same path
|
||||
//! gnome-remote-desktop uses for headless sessions — not the xdg portal, which needs an
|
||||
//! interactive grant):
|
||||
//!
|
||||
//! 1. `org.gnome.Mutter.RemoteDesktop.CreateSession()` → a remote-desktop session (read its
|
||||
//! `SessionId`). The cast is anchored to it, and it's also the future input path.
|
||||
//! 2. `org.gnome.Mutter.ScreenCast.CreateSession({"remote-desktop-session-id": id})`.
|
||||
//! 3. `ScreenCast.Session.RecordVirtual({"cursor-mode": embedded})` → Mutter creates a **virtual
|
||||
//! monitor** and returns a Stream object.
|
||||
//! 4. `RemoteDesktop.Session.Start()` → the Stream signals `PipeWireStreamAdded(node_id)`.
|
||||
//!
|
||||
//! The virtual monitor's *size* follows the PipeWire format negotiation — Mutter adapts it to
|
||||
//! what the consumer asks for — so the client's exact WxH is plumbed into our consumer's format
|
||||
//! pod as the preferred size ([`VirtualOutput::preferred_mode`]) rather than passed here.
|
||||
//! Sessions die with the D-Bus connection, so a keepalive thread owns it (RAII teardown).
|
||||
//!
|
||||
//! Requires a running Mutter (`gnome-shell` session, or `gnome-shell --headless` for the
|
||||
//! headless host) on the session bus. GNOME is detected via `XDG_CURRENT_DESKTOP=GNOME` or
|
||||
//! forced with `PUNKTFUNK_COMPOSITOR=mutter`.
|
||||
//!
|
||||
//! **Per-client scaling** (`identity` policy §5.4): GNOME persists per-monitor scale to
|
||||
//! `monitors.xml` keyed by connector+vendor+product+**serial**, but Mutter mints a fresh serial
|
||||
//! (`0x%.6x`, a per-shell counter) for every `RecordVirtual` monitor and the API offers no way to
|
||||
//! pass a stable identity — so GNOME's own persistence can never rematch our virtual output. The
|
||||
//! host persists the scale instead ([`identity::ScaleMap`](crate::vdisplay::identity), keyed per
|
||||
//! client / per the policy): reapplied at connect via the mode's `preferred-scale` plus the
|
||||
//! topology `ApplyMonitorsConfig`, and the user's mid-session changes are polled from
|
||||
//! DisplayConfig and written back.
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ashpd::zbus;
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
const BUS_RD: &str = "org.gnome.Mutter.RemoteDesktop";
|
||||
const BUS_SC: &str = "org.gnome.Mutter.ScreenCast";
|
||||
const BUS_DC: &str = "org.gnome.Mutter.DisplayConfig";
|
||||
/// `ApplyMonitorsConfig` method: 1 = temporary (auto-reverts on the next monitor change —
|
||||
/// e.g. when our virtual output is torn down — so we never persist a layout to monitors.xml).
|
||||
const APPLY_TEMPORARY: u32 = 1;
|
||||
|
||||
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
|
||||
const CURSOR_EMBEDDED: u32 = 1;
|
||||
|
||||
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
|
||||
/// a monitor configuration. Each of these makes Mutter rebuild its monitor topology, and
|
||||
/// *concurrent* rebuilds have segfaulted gnome-shell on-glass twice now: the teardown-side race is
|
||||
/// documented at the teardown below, and on 2026-07-10 three simultaneous session setups (three
|
||||
/// `RecordVirtual` calls within ~200 µs plus an `ApplyMonitorsConfig`) crashed the shell inside
|
||||
/// `meta_monitor_manager_rebuild` — dropping the box to the GDM greeter until a DM restart. One
|
||||
/// mutation at a time also keeps [`wait_virtual_connector`] sound: with two virtual outputs
|
||||
/// appearing at once, "the connector absent from MY pre-snapshot" can name a sibling's monitor.
|
||||
/// Each session runs on its own dedicated thread (see [`session_thread`]), so blocking on a std
|
||||
/// mutex — including across the awaits of its single-threaded setup future — is safe.
|
||||
static TOPOLOGY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
||||
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
||||
pub struct MutterDisplay {
|
||||
/// Whether this display is the FIRST of its group (§6.1) — set by the registry before `create`.
|
||||
/// A later sibling **extends** into the already-exclusive desktop instead of re-applying the
|
||||
/// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
|
||||
/// session establishes topology as before).
|
||||
first_in_group: bool,
|
||||
/// The connecting client's cert fingerprint (set before [`create`](VirtualDisplay::create)) —
|
||||
/// keys the per-client persisted **scale** (GNOME can't persist it itself: Mutter mints a fresh
|
||||
/// EDID serial per `RecordVirtual` monitor, so `monitors.xml` never rematches; see
|
||||
/// [`identity::ScaleMap`](crate::vdisplay::identity)).
|
||||
client_fp: Option<[u8; 32]>,
|
||||
/// The identity slot the last `create` resolved — reported to the registry via
|
||||
/// [`last_identity_slot`](VirtualDisplay::last_identity_slot) to key the group arrangement +
|
||||
/// `/display/state` slot, like the KWin backend.
|
||||
last_slot: Option<u32>,
|
||||
}
|
||||
|
||||
impl MutterDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(MutterDisplay {
|
||||
first_in_group: true,
|
||||
client_fp: None,
|
||||
last_slot: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutter is usable when the host runs inside a GNOME session (its `RecordVirtual` D-Bus API
|
||||
/// drives the *live* compositor). Cheap signal: `XDG_CURRENT_DESKTOP` names GNOME — same basis
|
||||
/// as [`super::detect`], avoiding a blocking D-Bus round-trip on the enumeration path.
|
||||
pub fn is_available() -> bool {
|
||||
std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.map(|d| d.to_ascii_uppercase().contains("GNOME"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
impl VirtualDisplay for MutterDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"mutter"
|
||||
}
|
||||
|
||||
fn set_first_in_group(&mut self, first: bool) {
|
||||
self.first_in_group = first;
|
||||
}
|
||||
|
||||
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
|
||||
self.client_fp = fingerprint;
|
||||
}
|
||||
|
||||
fn last_identity_slot(&self) -> Option<u32> {
|
||||
self.last_slot
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Identity (§5.4): resolve the client's stable slot per the `identity` policy (Linux
|
||||
// defaults to Shared when unconfigured, like KWin) — it keys the registry's group
|
||||
// arrangement/state. Mutter can't carry the slot into the monitor's EDID (RecordVirtual
|
||||
// owns the identity), so the per-client scaling that policy promises is host-persisted
|
||||
// instead: the session thread reapplies the remembered scale and records the user's
|
||||
// in-session changes under `scale_key`.
|
||||
self.last_slot = crate::vdisplay::identity::resolve_slot(
|
||||
self.client_fp,
|
||||
(mode.width, mode.height),
|
||||
crate::vdisplay::policy::Identity::Shared,
|
||||
);
|
||||
let scale_key = crate::vdisplay::identity::scale_key(
|
||||
self.client_fp,
|
||||
(mode.width, mode.height),
|
||||
crate::vdisplay::policy::Identity::Shared,
|
||||
);
|
||||
let remembered_scale = crate::vdisplay::identity::scales()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&scale_key);
|
||||
if let Some(scale) = remembered_scale {
|
||||
tracing::info!(scale, "mutter: reapplying the client's saved display scale");
|
||||
}
|
||||
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 first_in_group = self.first_in_group;
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-mutter-vout".into())
|
||||
.spawn(move || {
|
||||
session_thread(
|
||||
setup_tx,
|
||||
stop_thread,
|
||||
mode,
|
||||
first_in_group,
|
||||
scale_key,
|
||||
remembered_scale,
|
||||
)
|
||||
})
|
||||
.context("spawn Mutter virtual-output thread")?;
|
||||
|
||||
// 45 s (was 20 s): setups now queue on TOPOLOGY_LOCK, so a session behind a slow sibling
|
||||
// (whose guard spans up to a ~10 s stream wait + 6 s connector wait + the apply) must
|
||||
// outwait it plus its own handshake before this fires.
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(45)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"),
|
||||
Err(_) => bail!("timed out creating the Mutter virtual monitor"),
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
"Mutter virtual monitor ready"
|
||||
);
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
Box::new(StopGuard(stop)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping this ends the keepalive thread, closing the D-Bus connection — Mutter then tears
|
||||
/// the remote-desktop + screencast sessions (and the virtual monitor) down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
|
||||
/// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a
|
||||
/// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it).
|
||||
/// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect,
|
||||
/// and the user's in-session changes are recorded back under the key (GNOME itself can't — see
|
||||
/// [`identity::ScaleMap`](crate::vdisplay::identity)).
|
||||
// TOPOLOGY_LOCK is deliberately held across the awaits of the setup/teardown sequences: each
|
||||
// session owns this dedicated OS thread and its own single-future runtime, so the guard never
|
||||
// blocks a shared executor — it blocks exactly the sibling session threads, which is the point
|
||||
// (see TOPOLOGY_LOCK).
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
fn session_thread(
|
||||
setup_tx: Sender<Result<u32, String>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
mode: Mode,
|
||||
first_in_group: bool,
|
||||
scale_key: String,
|
||||
remembered_scale: Option<f64>,
|
||||
) {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
// The whole setup — pre-snapshot → RecordVirtual → ApplyMonitorsConfig — is one
|
||||
// read-modify-write on Mutter's monitor state; hold TOPOLOGY_LOCK across it so concurrent
|
||||
// sessions can't interleave rebuilds (gnome-shell SIGSEGV) or poison each other's
|
||||
// connector diffs. Released before the keepalive park below.
|
||||
let topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
|
||||
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
||||
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
||||
// SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream.
|
||||
use crate::vdisplay::policy::Topology;
|
||||
let topo = crate::vdisplay::effective_topology();
|
||||
let topo_policy = matches!(topo, Topology::Primary | Topology::Exclusive);
|
||||
// Group-aware (§6.1): only the FIRST display of the group establishes the topology. A later
|
||||
// sibling extends into the already-exclusive desktop — re-applying the sole-monitor config would
|
||||
// disable the first session's virtual output (Mutter connectors are un-nameable, so we can't
|
||||
// build a config that keeps all group virtuals; skipping is the safe choice). *Concurrent
|
||||
// Mutter exclusive is on-glass-validation-pending; the APPLY_TEMPORARY revert when the FIRST
|
||||
// session leaves under a live sibling is a documented residual (design §7).*
|
||||
let want_config = first_in_group && topo_policy;
|
||||
if topo_policy && !first_in_group {
|
||||
tracing::info!(
|
||||
"mutter: joining an existing display group — extending (the first session owns the \
|
||||
exclusive/primary topology)"
|
||||
);
|
||||
}
|
||||
let exclusive = matches!(topo, Topology::Exclusive);
|
||||
// Snapshot the monitor layout BEFORE the virtual output exists — it's how we tell the new
|
||||
// connector apart, both for the topology apply and for tracking the scale the user sets on
|
||||
// it. Taken unconditionally now (scale tracking wants it even when we won't touch the
|
||||
// topology); failure just degrades to no-topology + no-scale-persistence, as before.
|
||||
let dc_pre = match display_config().await {
|
||||
Ok(dc) => match get_state(&dc).await {
|
||||
Ok(state) => Some((dc, state)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "mutter: GetCurrentState (pre) failed; topology + scale persistence off");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "mutter: DisplayConfig unavailable; topology + scale persistence off");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let session = match connect(mode, remembered_scale).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = setup_tx.send(Ok(session.node_id));
|
||||
|
||||
// Identify the virtual connector (present now, absent in the pre-snapshot), then — when this
|
||||
// session owns the topology — make it the PRIMARY monitor so the GNOME shell + new windows
|
||||
// land on the surface we stream. Without this, on a host that also has a physical monitor
|
||||
// attached, the virtual output is an empty extended desktop — you stream only the wallpaper.
|
||||
// Best-effort: any failure just logs and streaming continues unchanged.
|
||||
let mut tracked: Option<(zbus::Proxy<'static>, CurrentState, String)> = None;
|
||||
if let Some((dc, pre)) = dc_pre {
|
||||
match wait_virtual_connector(&dc, &pre).await {
|
||||
Ok((vconn, state)) => {
|
||||
if want_config {
|
||||
match make_virtual_primary(
|
||||
&dc,
|
||||
mode,
|
||||
&pre,
|
||||
&state,
|
||||
&vconn,
|
||||
exclusive,
|
||||
remembered_scale,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!(
|
||||
exclusive,
|
||||
"mutter: virtual output set as the primary monitor (physicals {})",
|
||||
if exclusive { "disabled" } else { "kept" }
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"mutter: could not set the virtual output primary; streaming continues — the desktop may render on the physical monitor"
|
||||
),
|
||||
}
|
||||
}
|
||||
tracked = Some((dc, pre, vconn));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"mutter: virtual connector not identified; topology + scale persistence off"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
drop(topology_guard);
|
||||
|
||||
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
|
||||
// read the virtual output's logical-monitor scale and persist a change the user made (GNOME
|
||||
// Settings mid-stream) under the client's key — polled rather than teardown-only so a host
|
||||
// crash/redeploy doesn't lose it.
|
||||
let mut known = remembered_scale.unwrap_or(1.0);
|
||||
let mut ticks: u32 = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
ticks = ticks.wrapping_add(1);
|
||||
if ticks % 25 == 0 {
|
||||
if let Some((dc, _, vconn)) = &tracked {
|
||||
persist_scale_change(dc, vconn, &scale_key, &mut known).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final scale read BEFORE Stop (the virtual output must still exist to be read).
|
||||
if let Some((dc, _, vconn)) = &tracked {
|
||||
persist_scale_change(dc, vconn, &scale_key, &mut known).await;
|
||||
}
|
||||
|
||||
// Tear down: STOP the screencast so Mutter removes the virtual output. We deliberately do NOT
|
||||
// re-assert the physical layout with our own ApplyMonitorsConfig. Issuing a monitor reconfig
|
||||
// while the just-removed high-refresh virtual output is still tearing down SIGSEGVs gnome-shell
|
||||
// on Mutter 50 + NVIDIA — observed live on home-worker-3: the teardown ApplyMonitorsConfig
|
||||
// returned "recipient disconnected from message bus" because the shell crashed mid-call, after
|
||||
// which GDM's crash-loop guard dropped to the greeter and wedged EVERY subsequent reconnect.
|
||||
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
||||
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
|
||||
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
||||
// The Stop (+ the revert it triggers) is a topology mutation too — take TOPOLOGY_LOCK so a
|
||||
// sibling's teardown or setup can't interleave with the rebuild it causes.
|
||||
let _topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||
drop(tracked);
|
||||
});
|
||||
}
|
||||
|
||||
/// The live session objects (held for the stream's lifetime) + the PipeWire node id.
|
||||
struct MutterSession {
|
||||
rd_session: zbus::Proxy<'static>,
|
||||
_sc_session: zbus::Proxy<'static>,
|
||||
_conn: zbus::Connection,
|
||||
node_id: u32,
|
||||
}
|
||||
|
||||
/// Run the four-step handshake (see module docs). `preferred_scale` is the client's remembered
|
||||
/// desktop scale, passed as the virtual mode's `preferred-scale` so Mutter creates the monitor
|
||||
/// already scaled (Mutter ≥ 48; older Mutter ignores unknown mode keys) — this covers the
|
||||
/// `extend` topology, where we never issue our own ApplyMonitorsConfig.
|
||||
async fn connect(mode: Mode, preferred_scale: Option<f64>) -> Result<MutterSession> {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.context("connect session D-Bus")?;
|
||||
|
||||
// 1. RemoteDesktop session (the anchor; also the future input path).
|
||||
let rd = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_RD,
|
||||
"/org/gnome/Mutter/RemoteDesktop",
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
)
|
||||
.await
|
||||
.context("RemoteDesktop proxy (is gnome-shell / `gnome-shell --headless` running?)")?;
|
||||
let rd_path: OwnedObjectPath = rd
|
||||
.call("CreateSession", &())
|
||||
.await
|
||||
.context("RemoteDesktop.CreateSession")?;
|
||||
let rd_session = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_RD,
|
||||
rd_path,
|
||||
"org.gnome.Mutter.RemoteDesktop.Session",
|
||||
)
|
||||
.await?;
|
||||
let session_id: String = rd_session
|
||||
.get_property("SessionId")
|
||||
.await
|
||||
.context("read SessionId")?;
|
||||
|
||||
// 2. ScreenCast session anchored to it.
|
||||
let sc = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
"/org/gnome/Mutter/ScreenCast",
|
||||
"org.gnome.Mutter.ScreenCast",
|
||||
)
|
||||
.await
|
||||
.context("ScreenCast proxy")?;
|
||||
let mut props: HashMap<&str, Value> = HashMap::new();
|
||||
props.insert("remote-desktop-session-id", Value::from(session_id));
|
||||
let sc_path: OwnedObjectPath = sc
|
||||
.call("CreateSession", &(props,))
|
||||
.await
|
||||
.context("ScreenCast.CreateSession")?;
|
||||
let sc_session = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
sc_path,
|
||||
"org.gnome.Mutter.ScreenCast.Session",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. The virtual monitor. For >60 Hz we pin the client's exact WxH@Hz via RecordVirtual's
|
||||
// "modes" (explicit size + refresh-rate; Mutter ≥ 47) — validated at 5120×1440@240 on Mutter 50
|
||||
// + NVIDIA. At ≤60 Hz we let Mutter derive the refresh from the PipeWire framerate (its 60 Hz
|
||||
// default is already correct), so the custom-mode path only runs when it buys something.
|
||||
// (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown, which is why this was
|
||||
// once gated behind PUNKTFUNK_MUTTER_VIRTUAL_REFRESH; the stop-screencast-before-any-monitor-
|
||||
// reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
|
||||
let mut rec: HashMap<&str, Value> = HashMap::new();
|
||||
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
|
||||
if mode.refresh_hz > 60 || preferred_scale.is_some() {
|
||||
let mut vmode: HashMap<&str, Value> = HashMap::new();
|
||||
vmode.insert("size", Value::from((mode.width, mode.height)));
|
||||
// Only pin the refresh when it buys something (see above) — a remembered scale alone
|
||||
// rides Mutter's 60 Hz default, exactly like the no-modes path did.
|
||||
if mode.refresh_hz > 60 {
|
||||
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
|
||||
}
|
||||
if let Some(scale) = preferred_scale {
|
||||
vmode.insert("preferred-scale", Value::from(scale));
|
||||
}
|
||||
vmode.insert("is-preferred", Value::from(true));
|
||||
rec.insert("modes", Value::from(vec![vmode]));
|
||||
}
|
||||
let stream_path: OwnedObjectPath = sc_session
|
||||
.call("RecordVirtual", &(rec,))
|
||||
.await
|
||||
.context("Session.RecordVirtual")?;
|
||||
let stream = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
stream_path,
|
||||
"org.gnome.Mutter.ScreenCast.Stream",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Subscribe to the node-id signal BEFORE starting, then start the (combined) session.
|
||||
let mut added = stream
|
||||
.receive_signal("PipeWireStreamAdded")
|
||||
.await
|
||||
.context("subscribe PipeWireStreamAdded")?;
|
||||
rd_session
|
||||
.call_method("Start", &())
|
||||
.await
|
||||
.context("RemoteDesktop.Session.Start")?;
|
||||
let msg = tokio::time::timeout(Duration::from_secs(10), added.next())
|
||||
.await
|
||||
.map_err(|_| anyhow!("PipeWireStreamAdded did not arrive within 10s"))?
|
||||
.ok_or_else(|| anyhow!("signal stream ended before PipeWireStreamAdded"))?;
|
||||
let (node_id,): (u32,) = msg
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("PipeWireStreamAdded body")?;
|
||||
|
||||
Ok(MutterSession {
|
||||
rd_session,
|
||||
_sc_session: sc_session,
|
||||
_conn: conn,
|
||||
node_id,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Optional: make the per-session virtual output the PRIMARY monitor (PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY).
|
||||
//
|
||||
// `RecordVirtual` adds the virtual monitor as an *extended* desktop. On a headless host that's the
|
||||
// only display, so the shell + windows live there. But when a physical monitor is attached, GNOME
|
||||
// keeps it primary and the virtual output is an empty extension — the stream shows only the
|
||||
// wallpaper. We fix that by promoting the virtual output to primary (physical kept on, secondary)
|
||||
// via `org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig`, and restore on teardown.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `org.gnome.Mutter.DisplayConfig.GetCurrentState` reply shapes (see the interface XML):
|
||||
/// monitors: `a((ssss)a(siiddada{sv})a{sv})`
|
||||
/// logical_monitors: `a(iiduba(ssss)a{sv})`
|
||||
type MonitorSpec = (String, String, String, String); // connector, vendor, product, serial
|
||||
type DbusMode = (
|
||||
String,
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
f64,
|
||||
Vec<f64>,
|
||||
HashMap<String, OwnedValue>,
|
||||
);
|
||||
type MonitorInfo = (MonitorSpec, Vec<DbusMode>, HashMap<String, OwnedValue>);
|
||||
type LogicalMonitor = (
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
u32,
|
||||
bool,
|
||||
Vec<MonitorSpec>,
|
||||
HashMap<String, OwnedValue>,
|
||||
);
|
||||
type CurrentState = (
|
||||
u32,
|
||||
Vec<MonitorInfo>,
|
||||
Vec<LogicalMonitor>,
|
||||
HashMap<String, OwnedValue>,
|
||||
);
|
||||
|
||||
/// `ApplyMonitorsConfig` logical-monitor shape: `(iiduba(ssa{sv}))`, monitor = `(ssa{sv})`.
|
||||
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
|
||||
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
|
||||
|
||||
/// A DisplayConfig proxy on its own session-bus connection (owned, so it stays alive for the
|
||||
/// session — independent of the RemoteDesktop/ScreenCast connection).
|
||||
async fn display_config() -> Result<zbus::Proxy<'static>> {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.context("connect session D-Bus (DisplayConfig)")?;
|
||||
zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_DC,
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
"org.gnome.Mutter.DisplayConfig",
|
||||
)
|
||||
.await
|
||||
.context("DisplayConfig proxy")
|
||||
}
|
||||
|
||||
async fn get_state(dc: &zbus::Proxy<'_>) -> Result<CurrentState> {
|
||||
dc.call("GetCurrentState", &())
|
||||
.await
|
||||
.context("DisplayConfig.GetCurrentState")
|
||||
}
|
||||
|
||||
fn connectors(state: &CurrentState) -> HashSet<String> {
|
||||
state.1.iter().map(|m| m.0 .0.clone()).collect()
|
||||
}
|
||||
|
||||
fn mode_flag(md: &DbusMode, key: &str) -> bool {
|
||||
matches!(md.6.get(key).map(|v| &**v), Some(&Value::Bool(true)))
|
||||
}
|
||||
|
||||
/// The current (else preferred, else first) mode of `connector` → `(mode_id, width, height, refresh)`.
|
||||
fn current_mode_full(state: &CurrentState, connector: &str) -> Option<(String, i32, i32, f64)> {
|
||||
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
|
||||
let pick = mon
|
||||
.1
|
||||
.iter()
|
||||
.find(|md| mode_flag(md, "is-current"))
|
||||
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
|
||||
.or_else(|| mon.1.first())?;
|
||||
Some((pick.0.clone(), pick.1, pick.2, pick.3))
|
||||
}
|
||||
|
||||
/// As [`current_mode_full`] but dropping the refresh (callers that only place by width).
|
||||
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
|
||||
current_mode_full(state, connector).map(|(id, w, h, _)| (id, w, h))
|
||||
}
|
||||
|
||||
/// Pure mode-pick for a KEPT physical (unit-tested). Given the physical's PRE-connect mode
|
||||
/// (`pre_mode = (id, w, h, refresh)`; `None` when the connector is new since the snapshot) and the
|
||||
/// mode list Mutter reports for it in the POST-virtual state
|
||||
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width)` to re-apply.
|
||||
///
|
||||
/// Mutter re-derives its layout when the `RecordVirtual` output appears and can silently drop a
|
||||
/// 120 Hz panel to its EDID-preferred 60 Hz — so the post-virtual `is-current` is *already* 60 Hz.
|
||||
/// We therefore prefer the PRE mode (its real refresh), resolved to a mode id valid at apply time;
|
||||
/// only when the physical genuinely no longer offers that mode do we fall back to the post-virtual
|
||||
/// current (never inventing a mode id `ApplyMonitorsConfig` would reject).
|
||||
fn pick_keep_mode(
|
||||
pre_mode: Option<(String, i32, i32, f64)>,
|
||||
state_modes: &[(String, i32, i32, f64, bool, bool)],
|
||||
) -> Option<(String, i32)> {
|
||||
let state_current = || {
|
||||
state_modes
|
||||
.iter()
|
||||
.find(|m| m.4)
|
||||
.or_else(|| state_modes.iter().find(|m| m.5))
|
||||
.or_else(|| state_modes.first())
|
||||
.map(|m| (m.0.clone(), m.1))
|
||||
};
|
||||
let Some((pre_id, w, h, hz)) = pre_mode else {
|
||||
return state_current();
|
||||
};
|
||||
// The exact pre mode id, if the connector still offers it (same session ⇒ usually true).
|
||||
if state_modes.iter().any(|m| m.0 == pre_id) {
|
||||
return Some((pre_id, w));
|
||||
}
|
||||
// Else a re-keyed id with the same geometry + refresh (still the real 120 Hz).
|
||||
if let Some(m) = state_modes
|
||||
.iter()
|
||||
.find(|m| m.1 == w && m.2 == h && (m.3 - hz).abs() < 0.5)
|
||||
{
|
||||
return Some((m.0.clone(), m.1));
|
||||
}
|
||||
// The physical genuinely no longer offers that mode — use whatever is valid now.
|
||||
state_current()
|
||||
}
|
||||
|
||||
/// The `(mode_id, width)` a kept physical should be RE-APPLIED at — its PRE-connect mode preserved
|
||||
/// across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
|
||||
fn physical_keep_mode(
|
||||
pre: &CurrentState,
|
||||
state: &CurrentState,
|
||||
conn: &str,
|
||||
) -> Option<(String, i32)> {
|
||||
let pre_mode = current_mode_full(pre, conn);
|
||||
let state_modes: Vec<(String, i32, i32, f64, bool, bool)> = state
|
||||
.1
|
||||
.iter()
|
||||
.find(|m| m.0 .0 == conn)
|
||||
.map(|mon| {
|
||||
mon.1
|
||||
.iter()
|
||||
.map(|md| {
|
||||
(
|
||||
md.0.clone(),
|
||||
md.1,
|
||||
md.2,
|
||||
md.3,
|
||||
mode_flag(md, "is-current"),
|
||||
mode_flag(md, "is-preferred"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
pick_keep_mode(pre_mode, &state_modes)
|
||||
}
|
||||
|
||||
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
|
||||
/// which lands shortly after the node id) and return its connector name (present now, absent in
|
||||
/// the pre-snapshot) plus the state that contained it.
|
||||
async fn wait_virtual_connector(
|
||||
dc: &zbus::Proxy<'_>,
|
||||
pre: &CurrentState,
|
||||
) -> Result<(String, CurrentState)> {
|
||||
let pre_conns = connectors(pre);
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
loop {
|
||||
let state = get_state(dc).await?;
|
||||
let virt = state
|
||||
.1
|
||||
.iter()
|
||||
.map(|m| m.0 .0.clone())
|
||||
.find(|c| !pre_conns.contains(c));
|
||||
if let Some(vconn) = virt {
|
||||
return Ok((vconn, state));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("the virtual monitor did not appear in DisplayConfig within 6s");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the virtual output the primary output — SOLE (`exclusive`: physicals disabled for the
|
||||
/// session) or with the physicals kept as secondaries — so the cursor, windows, and keyboard focus
|
||||
/// stay on the streamed surface. Applied at the client's `remembered_scale` (validated against the
|
||||
/// mode's supported scales; 1.0 when none is remembered) so a saved DPI setting survives the
|
||||
/// reconnect. Reverted by Mutter on teardown (APPLY_TEMPORARY).
|
||||
async fn make_virtual_primary(
|
||||
dc: &zbus::Proxy<'_>,
|
||||
mode: Mode,
|
||||
pre: &CurrentState,
|
||||
state: &CurrentState,
|
||||
vconn: &str,
|
||||
exclusive: bool,
|
||||
remembered_scale: Option<f64>,
|
||||
) -> Result<()> {
|
||||
// Prefer the mode matching the client's WxH; fall back to whatever is current.
|
||||
let vmode = state
|
||||
.1
|
||||
.iter()
|
||||
.find(|m| m.0 .0 == vconn)
|
||||
.and_then(|m| {
|
||||
m.1.iter()
|
||||
.find(|md| md.1 == mode.width as i32 && md.2 == mode.height as i32)
|
||||
.map(|md| md.0.clone())
|
||||
})
|
||||
.or_else(|| current_mode(state, vconn).map(|(id, _, _)| id));
|
||||
let Some(vmode) = vmode else {
|
||||
bail!("virtual monitor {vconn} has no usable mode yet");
|
||||
};
|
||||
// The scale to apply. Mutter (≥ its `preferred-scale` support) already derived the virtual's
|
||||
// logical monitor at the remembered scale we passed to RecordVirtual, PRE-VALIDATED — preserve
|
||||
// that instead of forcing a value (forcing 1.0 here was the original scale-clobber bug). On an
|
||||
// older Mutter the derived scale stays 1.0 while a scale is remembered — try the remembered
|
||||
// value snapped to an integral logical size (Mutter's fractional-scaling validity rule;
|
||||
// GetCurrentState reports NO supported-scales for virtual monitors to snap to), and retry at
|
||||
// the derived scale if the whole apply is rejected (an invalid scale fails the entire config —
|
||||
// losing the primary switch over scaling would be worse).
|
||||
let derived = logical_scale(state, vconn)
|
||||
.filter(|s| s.is_finite() && *s > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
let mut scale = match remembered_scale {
|
||||
Some(want) if (want - derived).abs() > 1e-3 => {
|
||||
snap_integral_scale(want, mode.width, mode.height)
|
||||
}
|
||||
_ => derived,
|
||||
};
|
||||
loop {
|
||||
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
|
||||
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
|
||||
// (On a headless host with no physicals the two are identical.)
|
||||
let config = if exclusive {
|
||||
build_exclusive_config(vconn, &vmode, scale)
|
||||
} else {
|
||||
build_primary_keeping_physicals(pre, state, vconn, &vmode, mode.width as i32, scale)
|
||||
};
|
||||
let res: zbus::Result<()> = dc
|
||||
.call(
|
||||
"ApplyMonitorsConfig",
|
||||
&(
|
||||
state.0,
|
||||
APPLY_TEMPORARY,
|
||||
config,
|
||||
HashMap::<String, Value<'static>>::new(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if (scale - derived).abs() > 1e-3 => {
|
||||
tracing::warn!(
|
||||
scale,
|
||||
derived,
|
||||
error = %format!("{e:#}"),
|
||||
"mutter: ApplyMonitorsConfig at the remembered scale failed — retrying at the derived scale"
|
||||
);
|
||||
scale = derived;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e).context("DisplayConfig.ApplyMonitorsConfig (set virtual primary)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap `want` to the nearest scale that gives the mode an **integral logical size** — Mutter only
|
||||
/// accepts fractional scales where both `width/scale` and `height/scale` are integers, and its
|
||||
/// GetCurrentState reports no `supported-scales` for virtual monitors to snap to. Searches the few
|
||||
/// logical widths around the target for one that keeps the aspect exact; falls back to `want`
|
||||
/// unchanged (the caller retries at the derived scale if Mutter still rejects it). Pure, unit-tested.
|
||||
fn snap_integral_scale(want: f64, width: u32, height: u32) -> f64 {
|
||||
if !want.is_finite() || want <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let (w, h) = (width as i64, height as i64);
|
||||
let target = (w as f64 / want).round() as i64;
|
||||
(target - 8..=target + 8)
|
||||
.filter(|lw| *lw >= 1 && (h * lw) % w == 0)
|
||||
.map(|lw| w as f64 / lw as f64)
|
||||
.min_by(|a, b| (a - want).abs().total_cmp(&(b - want).abs()))
|
||||
.unwrap_or(want)
|
||||
}
|
||||
|
||||
/// The scale of the logical monitor carrying `connector`, if present.
|
||||
fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
||||
state
|
||||
.2
|
||||
.iter()
|
||||
.find(|l| l.5.iter().any(|spec| spec.0 == connector))
|
||||
.map(|l| l.2)
|
||||
}
|
||||
|
||||
/// Read the virtual output's current scale and, when the user changed it (GNOME Settings
|
||||
/// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it.
|
||||
/// Best-effort: read failures (teardown races, shell restart) are silently skipped.
|
||||
async fn persist_scale_change(dc: &zbus::Proxy<'_>, vconn: &str, scale_key: &str, known: &mut f64) {
|
||||
let Ok(state) = get_state(dc).await else {
|
||||
return;
|
||||
};
|
||||
let Some(cur) = logical_scale(&state, vconn) else {
|
||||
return;
|
||||
};
|
||||
if (cur - *known).abs() > 1e-3 {
|
||||
crate::vdisplay::identity::scales()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set(scale_key, cur);
|
||||
*known = cur;
|
||||
tracing::info!(
|
||||
scale = cur,
|
||||
"mutter: persisted the client's display scale for the next connect"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so
|
||||
/// Mutter disables them for the session. This confines the cursor, windows, and keyboard focus to the
|
||||
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
||||
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
||||
/// vanish). The physical layout is restored on teardown.
|
||||
fn build_exclusive_config(vconn: &str, vmode: &str, scale: f64) -> Vec<ApplyLogical> {
|
||||
vec![(
|
||||
0,
|
||||
0,
|
||||
scale,
|
||||
0,
|
||||
true,
|
||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||
)]
|
||||
}
|
||||
|
||||
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
|
||||
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
|
||||
/// mode). So the shell + new windows land on the streamed surface, but the operator's physical
|
||||
/// screen stays on **at its real refresh**. On a headless host (no physicals) this is identical to
|
||||
/// [`build_exclusive_config`].
|
||||
///
|
||||
/// `pre` is the snapshot taken *before* the virtual output existed (physical still at its true
|
||||
/// refresh); `state` is the post-virtual state. We read each physical's mode from `pre` because
|
||||
/// Mutter can knock a 120 Hz panel down to 60 Hz when it re-derives the layout for the virtual
|
||||
/// monitor — reading `state` would cement that 60 Hz (`physical_keep_mode`).
|
||||
///
|
||||
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
|
||||
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
|
||||
fn build_primary_keeping_physicals(
|
||||
pre: &CurrentState,
|
||||
state: &CurrentState,
|
||||
vconn: &str,
|
||||
vmode: &str,
|
||||
virt_width: i32,
|
||||
scale: f64,
|
||||
) -> Vec<ApplyLogical> {
|
||||
let mut logicals: Vec<ApplyLogical> = vec![(
|
||||
0,
|
||||
0,
|
||||
scale,
|
||||
0,
|
||||
true,
|
||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||
)];
|
||||
// Append each physical (non-virtual) connector that has a usable mode, to the right of the
|
||||
// virtual output, as a non-primary secondary — at its PRE-connect mode (real refresh preserved).
|
||||
// Offsets are in the layout's coordinate space: LOGICAL pixels by default on Wayland (the
|
||||
// virtual's footprint is width/scale), physical pixels only under layout-mode 2.
|
||||
let physical_layout = matches!(
|
||||
state.3.get("layout-mode").map(|v| &**v),
|
||||
Some(&Value::U32(2))
|
||||
);
|
||||
let virt_logical_width = if physical_layout {
|
||||
virt_width
|
||||
} else {
|
||||
((virt_width as f64 / scale).round() as i32).max(1)
|
||||
};
|
||||
let mut x = virt_logical_width.max(0);
|
||||
for mon in &state.1 {
|
||||
let conn = &mon.0 .0;
|
||||
if conn == vconn {
|
||||
continue;
|
||||
}
|
||||
if let Some((mode_id, w)) = physical_keep_mode(pre, state, conn) {
|
||||
logicals.push((
|
||||
x,
|
||||
0,
|
||||
1.0,
|
||||
0,
|
||||
false,
|
||||
vec![(conn.clone(), mode_id, HashMap::new())],
|
||||
));
|
||||
x += w.max(0);
|
||||
}
|
||||
}
|
||||
logicals
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick_keep_mode, snap_integral_scale};
|
||||
|
||||
// (id, w, h, refresh, is_current, is_preferred)
|
||||
fn m(
|
||||
id: &str,
|
||||
w: i32,
|
||||
h: i32,
|
||||
hz: f64,
|
||||
cur: bool,
|
||||
pref: bool,
|
||||
) -> (String, i32, i32, f64, bool, bool) {
|
||||
(id.to_string(), w, h, hz, cur, pref)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_prefers_pre_refresh_over_downgraded_state() {
|
||||
// Physical was 2560x1440@120 pre-connect; after the virtual appeared Mutter marked 60 Hz
|
||||
// current (the reported bug). We must re-apply the 120 Hz mode, not the state's 60 Hz.
|
||||
let pre = Some(("M120".to_string(), 2560, 1440, 120.0));
|
||||
let state = vec![
|
||||
m("M120", 2560, 1440, 120.0, false, false),
|
||||
m("M60", 2560, 1440, 60.0, true, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("M120".to_string(), 2560))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_rekeyed_id_matches_by_geometry_and_refresh() {
|
||||
// The pre id is no longer offered (Mutter re-keyed the mode list), but a 120 Hz mode of the
|
||||
// same geometry exists — match it so the real refresh survives.
|
||||
let pre = Some(("old-120".to_string(), 2560, 1440, 120.0));
|
||||
let state = vec![
|
||||
m("new-120", 2560, 1440, 119.998, false, false),
|
||||
m("new-60", 2560, 1440, 60.0, true, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("new-120".to_string(), 2560))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_falls_back_to_state_current_when_pre_mode_gone() {
|
||||
// The physical genuinely no longer offers its pre mode (e.g. cable renegotiated to a lower
|
||||
// max) — never invent an id; use the post-virtual current.
|
||||
let pre = Some(("gone-165".to_string(), 3440, 1440, 165.0));
|
||||
let state = vec![
|
||||
m("s-100", 3440, 1440, 100.0, true, false),
|
||||
m("s-60", 3440, 1440, 60.0, false, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("s-100".to_string(), 3440))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_integral_scale_keeps_valid_scales_and_snaps_odd_ones() {
|
||||
// Already-integral scales survive exactly: 1920/1.5 = 1280, 1080/1.5 = 720.
|
||||
assert_eq!(snap_integral_scale(1.5, 1920, 1080), 1.5);
|
||||
// The GNOME fractional 1.6666… on 3840x2400 (logical 2304x1440) survives.
|
||||
let s = snap_integral_scale(1.666_666_6, 3840, 2400);
|
||||
assert!((s - 3840.0 / 2304.0).abs() < 1e-9, "got {s}");
|
||||
// A scale with no integral logical size nearby snaps to the closest one that has it:
|
||||
// 16:9 logical widths must be multiples of 16 → 1.3 snaps to 1920/1472.
|
||||
let s = snap_integral_scale(1.3, 1920, 1080);
|
||||
assert!((s - 1920.0 / 1472.0).abs() < 1e-9, "got {s}");
|
||||
// Junk input degrades to 1.0.
|
||||
assert_eq!(snap_integral_scale(f64::NAN, 1920, 1080), 1.0);
|
||||
assert_eq!(snap_integral_scale(-2.0, 1920, 1080), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_no_pre_uses_state_current_then_preferred() {
|
||||
// A connector new since the pre-snapshot (no pre mode): is-current wins, else is-preferred.
|
||||
let state = vec![
|
||||
m("A", 1920, 1080, 60.0, true, false),
|
||||
m("B", 1920, 1080, 144.0, false, true),
|
||||
];
|
||||
assert_eq!(pick_keep_mode(None, &state), Some(("A".to_string(), 1920)));
|
||||
|
||||
let no_current = vec![
|
||||
m("A", 1920, 1080, 60.0, false, false),
|
||||
m("B", 1920, 1080, 144.0, false, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(None, &no_current),
|
||||
Some(("B".to_string(), 1920))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
//! wlroots/Sway virtual-output backend via sway IPC + the xdg ScreenCast portal
|
||||
//! (xdg-desktop-portal-wlr):
|
||||
//!
|
||||
//! 1. `swaymsg create_output` adds a headless output (`HEADLESS-N` — sway must run the
|
||||
//! headless backend, or have it co-loaded; the name is found by diffing
|
||||
//! `swaymsg -t get_outputs` before/after).
|
||||
//! 2. `swaymsg output <NAME> mode --custom WxH@HzHz` sets the client's exact mode — a fresh
|
||||
//! headless output also *needs* a real mode for a refresh clock, or it produces no frames.
|
||||
//! 3. The ScreenCast portal yields the output's PipeWire node. There is no GUI to pick an
|
||||
//! output headlessly, so xdpw is steered through its chooser hook: a managed config
|
||||
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
|
||||
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
|
||||
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
|
||||
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
|
||||
//!
|
||||
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
|
||||
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
|
||||
//! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast
|
||||
//! interface routed to xdpw (`scripts/headless/portals.conf`).
|
||||
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// File the xdpw output chooser reads the selected output from (see [`xdpw_config`]); we
|
||||
/// write `Monitor: <NAME>\n` here right before the portal handshake selects sources. Lives
|
||||
/// under `$XDG_RUNTIME_DIR` (per-user, mode 0700) — NOT a fixed world-writable /tmp path,
|
||||
/// where another local user could pre-create it (DoS) or rewrite it between our write and
|
||||
/// xdpw's read (steer capture at a different output).
|
||||
fn chooser_file() -> String {
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{dir}/punktfunk-xdpw-output")
|
||||
}
|
||||
|
||||
/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback
|
||||
/// keeps plain portal capture (`--source portal` flow) working when no session has written
|
||||
/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout.
|
||||
fn xdpw_config() -> String {
|
||||
format!(
|
||||
"# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\
|
||||
[screencast]\n\
|
||||
chooser_type=simple\n\
|
||||
chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n",
|
||||
chooser_file()
|
||||
)
|
||||
}
|
||||
|
||||
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
|
||||
/// adds one headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct WlrootsDisplay;
|
||||
|
||||
impl WlrootsDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(WlrootsDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK`
|
||||
/// (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration path.
|
||||
pub fn is_available() -> bool {
|
||||
std::env::var_os("SWAYSOCK").is_some()
|
||||
}
|
||||
|
||||
impl VirtualDisplay for WlrootsDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"wlroots"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
let before = output_names()
|
||||
.context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?;
|
||||
swaymsg(&["create_output"])
|
||||
.context("swaymsg create_output (sway needs the headless backend loaded)")?;
|
||||
// The output appears synchronously in practice; poll briefly to be safe, and own it
|
||||
// from here on so error unwinding unplugs it.
|
||||
let output = OutputGuard(wait_new_output(&before, Duration::from_secs(5))?);
|
||||
let name = output.0.clone();
|
||||
|
||||
// The client's exact mode (also the refresh clock that makes the output produce frames).
|
||||
let m = format!(
|
||||
"{}x{}@{}Hz",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz.max(1)
|
||||
);
|
||||
swaymsg(&["output", &name, "mode", "--custom", &m])
|
||||
.with_context(|| format!("swaymsg output {name} mode --custom {m}"))?;
|
||||
swaymsg(&["output", &name, "enable"])
|
||||
.with_context(|| format!("swaymsg output {name} enable"))?;
|
||||
|
||||
// Steer xdpw's headless output chooser at our new output, then run the portal
|
||||
// handshake on its own thread (it parks to keep the cast alive, like the other backends).
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {name}\n"))
|
||||
.with_context(|| format!("write {chooser}"))?;
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
"sway headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(Keepalive {
|
||||
_stop: StopGuard(stop),
|
||||
_output: output,
|
||||
}),
|
||||
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
|
||||
// portal fd can't be re-opened per attach, so the registry passes it through on
|
||||
// `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach).
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast),
|
||||
/// then unplug the output (fields drop in declaration order).
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal
|
||||
/// then tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the created headless output; dropping it unplugs it from sway.
|
||||
struct OutputGuard(String);
|
||||
|
||||
impl Drop for OutputGuard {
|
||||
fn drop(&mut self) {
|
||||
match swaymsg(&["output", &self.0, "unplug"]) {
|
||||
Ok(_) => tracing::info!(output = %self.0, "sway headless output unplugged"),
|
||||
Err(e) => tracing::warn!(output = %self.0, error = %format!("{e:#}"), "unplug failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `swaymsg -- <args>`, returning stdout (`--` so command tokens like `--custom` reach
|
||||
/// sway instead of swaymsg's own getopt). swaymsg exits non-zero (with the error on stderr/
|
||||
/// stdout) when the command fails, so checking the status covers `{"success": false}` too.
|
||||
fn swaymsg(args: &[&str]) -> Result<String> {
|
||||
let out = Command::new("swaymsg")
|
||||
.arg("--")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg {:?} failed: {}{}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stdout).trim(),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Current output names from `swaymsg -t get_outputs` (JSON).
|
||||
fn output_names() -> Result<Vec<String>> {
|
||||
let out = Command::new("swaymsg")
|
||||
.args(["-t", "get_outputs", "--raw"])
|
||||
.output()
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg -t get_outputs failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let outputs: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?;
|
||||
Ok(outputs
|
||||
.as_array()
|
||||
.context("get_outputs: not an array")?
|
||||
.iter()
|
||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(str::to_owned))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N).
|
||||
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(name) = output_names()?
|
||||
.into_iter()
|
||||
.find(|n| !before.iter().any(|b| b == n))
|
||||
{
|
||||
return Ok(name);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("create_output succeeded but no new output appeared");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure xdpw uses our output chooser. xdpw reads its config only at startup, so on a
|
||||
/// change restart it if running (`try-restart`; if it isn't, D-Bus activation will start it
|
||||
/// with the new config). The config itself is static — the *selection* is the chooser file.
|
||||
fn ensure_xdpw_config() -> Result<()> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
|
||||
let dir = base.join("xdg-desktop-portal-wlr");
|
||||
let path = dir.join("config");
|
||||
let cfg = xdpw_config();
|
||||
if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
|
||||
std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?;
|
||||
tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-wlr config");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"])
|
||||
.status();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ScreenCast portal handshake (same shape as the capture module's portal thread, but it
|
||||
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
|
||||
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
|
||||
fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<AtomicBool>) {
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser declined? check the xdpw config/chooser file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped —
|
||||
// the cast is torn down when the connection drops.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,862 +0,0 @@
|
||||
//! Virtual-display **management policy** — the user-configurable behavior surface for how virtual
|
||||
//! displays are created, kept alive, and arranged (design: `design/display-management.md`).
|
||||
//!
|
||||
//! This is the pure config layer that sits **above** the per-compositor [`VirtualDisplay`](super)
|
||||
//! backends: a small set of orthogonal options ([`DisplayPolicy`]) with safe defaults and named
|
||||
//! [`Preset`]s, persisted to `<config>/display-settings.json` and editable from the web console.
|
||||
//! The lifecycle/registry that *acts* on this policy lands in later stages; **Stage 0** (this file
|
||||
//! plus the mgmt endpoints) stands up the surface and wires the two behaviors the existing code can
|
||||
//! already express — the Windows monitor linger duration and the Linux "make the streamed output
|
||||
//! the sole desktop" topology — through it.
|
||||
//!
|
||||
//! Precedence, mirroring the GPU preference (`console preference > env pin > default`): a present,
|
||||
//! valid `display-settings.json` (console-written) **wins**; when it is absent the host keeps its
|
||||
//! historical env-knob / default behavior untouched ([`DisplayPolicyStore::configured`] returns
|
||||
//! `None`, and every Stage-0 call site falls back to exactly what it did before). The policy is
|
||||
//! read at each acquire/teardown (file state, not a startup-frozen env var), so a console change
|
||||
//! applies to the next connect without a host restart.
|
||||
//!
|
||||
//! The pure logic here — preset expansion, [`DisplayPolicy::effective`], the [`KeepAlive`] linger
|
||||
//! resolution — is unit-tested; the store adds file I/O around it (the `gpu.rs` discipline:
|
||||
//! private dir, temp-write + atomic rename, in-memory rollback on a failed write).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
|
||||
/// survives after the last client session detaches. Serialized as an object tagged on `mode`
|
||||
/// (`{"mode":"off"}` / `{"mode":"duration","seconds":300}` / `{"mode":"forever"}`) so the web form
|
||||
/// and the OpenAPI schema stay simple.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||
pub enum KeepAlive {
|
||||
/// Tear the display down at session end (today's default on every backend but Windows, which
|
||||
/// lingers 10 s).
|
||||
Off,
|
||||
/// Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect
|
||||
/// inside the window reuses it.
|
||||
Duration {
|
||||
/// Linger window in seconds.
|
||||
seconds: u32,
|
||||
},
|
||||
/// Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).
|
||||
/// **Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.
|
||||
Forever,
|
||||
}
|
||||
|
||||
impl Default for KeepAlive {
|
||||
fn default() -> Self {
|
||||
// The historical Windows behavior, made explicit; the Linux backends had no linger and map
|
||||
// `Off`/short-duration onto their (nonexistent) keep-alive as a no-op until the lifecycle stage.
|
||||
KeepAlive::Duration { seconds: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved linger for the display lifecycle: teardown immediately, after a fixed window, or never.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Linger {
|
||||
/// Tear down as soon as the last session leaves.
|
||||
Immediate,
|
||||
/// Linger for this window, then tear down.
|
||||
For(Duration),
|
||||
/// Never auto-tear-down (Pinned).
|
||||
Forever,
|
||||
}
|
||||
|
||||
impl KeepAlive {
|
||||
/// The [`Linger`] this keep-alive resolves to.
|
||||
pub fn linger(self) -> Linger {
|
||||
match self {
|
||||
KeepAlive::Off => Linger::Immediate,
|
||||
KeepAlive::Duration { seconds } => Linger::For(Duration::from_secs(seconds as u64)),
|
||||
KeepAlive::Forever => Linger::Forever,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the host does to the box's display topology while managed virtual displays are up.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Topology {
|
||||
/// Today's behavior, resolved per host at acquire time (see [`super::effective_topology`]):
|
||||
/// exclusive on Windows and the auto-detected Linux desktop path, extend under an explicit
|
||||
/// `PUNKTFUNK_COMPOSITOR` pin.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Add the virtual display(s); touch nothing else.
|
||||
Extend,
|
||||
/// Make the group's primary virtual display the OS primary; physical outputs stay enabled.
|
||||
Primary,
|
||||
/// The managed virtual displays become the only enabled outputs (physical outputs disabled,
|
||||
/// restored on teardown).
|
||||
Exclusive,
|
||||
}
|
||||
|
||||
/// Admission when a *different* client connects while a display/session is already live and asks for
|
||||
/// a different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModeConflict {
|
||||
/// Give the new client its own virtual display on the same desktop (today's Linux multi-view).
|
||||
#[default]
|
||||
Separate,
|
||||
/// Stop the existing session(s), tear down / reconfigure, serve the new client.
|
||||
Steal,
|
||||
/// Admit the new client at the live display's mode (the honest-downgrade convention).
|
||||
Join,
|
||||
/// Refuse the new client with a clear handshake error.
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored
|
||||
/// at Stage 0; carriers wired from the identity stage.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Identity {
|
||||
/// One identity for everything (today's Linux behavior).
|
||||
Shared,
|
||||
/// One identity per paired client cert fingerprint (today's Windows behavior).
|
||||
#[default]
|
||||
PerClient,
|
||||
/// One identity per (client, resolution) — distinct scaling per resolution, at the cost of
|
||||
/// identity slots.
|
||||
PerClientMode,
|
||||
}
|
||||
|
||||
/// How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from
|
||||
/// the multi-monitor stage.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum LayoutMode {
|
||||
/// Left-to-right in acquire order, top-aligned (deterministic default).
|
||||
#[default]
|
||||
AutoRow,
|
||||
/// Per-identity-slot offsets from [`Layout::positions`] (console-arranged).
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// A desktop-space offset for a display (top-left origin).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by
|
||||
/// identity-slot id (string keys for stable JSON).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct Layout {
|
||||
#[serde(default)]
|
||||
pub mode: LayoutMode,
|
||||
#[serde(default)]
|
||||
pub positions: BTreeMap<String, Position>,
|
||||
}
|
||||
|
||||
/// How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is
|
||||
/// served (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes
|
||||
/// — a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers
|
||||
/// it. Linux-only in effect (a launching Windows session opens into the one desktop).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GameSession {
|
||||
/// Today's routing: the launch rides whatever session the box is in (managed Steam session on
|
||||
/// Bazzite/SteamOS, bare spawn on plain distros, spawned into the live desktop on KWin/Mutter/wlroots).
|
||||
#[default]
|
||||
Auto,
|
||||
/// A launching session always gets its OWN headless gamescope at the client's mode, nesting just
|
||||
/// the game — no Steam Big Picture, no game mode. Degrades to `auto` when gamescope is unavailable.
|
||||
Dedicated,
|
||||
}
|
||||
|
||||
/// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any
|
||||
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Preset {
|
||||
/// The explicit fields below define the policy.
|
||||
#[default]
|
||||
Custom,
|
||||
/// Today's behavior, made explicit.
|
||||
Default,
|
||||
/// Dedicated headless/couch box: displays + game survive disconnects; whoever connects takes over.
|
||||
GamingRig,
|
||||
/// A desktop someone also uses physically: never blank the real monitors, never keep ghosts.
|
||||
SharedDesktop,
|
||||
/// One user at a time with fast reattach; a second user is told the box is busy.
|
||||
Hotdesk,
|
||||
/// The multi-monitor daily driver: manual arrangement, per-client identity, exclusive.
|
||||
Workstation,
|
||||
}
|
||||
|
||||
/// The user-facing display-management policy — what `display-settings.json` holds and what the mgmt
|
||||
/// API GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are
|
||||
/// ignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a
|
||||
/// single [`EffectivePolicy`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DisplayPolicy {
|
||||
/// Schema version (currently 1) — lets a future field addition migrate rather than reject.
|
||||
#[serde(default = "one")]
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub preset: Preset,
|
||||
#[serde(default)]
|
||||
pub keep_alive: KeepAlive,
|
||||
#[serde(default)]
|
||||
pub topology: Topology,
|
||||
#[serde(default)]
|
||||
pub mode_conflict: ModeConflict,
|
||||
#[serde(default)]
|
||||
pub identity: Identity,
|
||||
#[serde(default)]
|
||||
pub layout: Layout,
|
||||
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
|
||||
#[serde(default = "default_max_displays")]
|
||||
pub max_displays: u32,
|
||||
/// How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).
|
||||
/// Orthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`
|
||||
/// so existing `display-settings.json` files are untouched.
|
||||
#[serde(default)]
|
||||
pub game_session: GameSession,
|
||||
/// EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →
|
||||
/// DPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.
|
||||
/// Targets the "connected-but-dark head" periodic-stutter class (monitor standby
|
||||
/// auto-input-scan / DP link churn while the virtual display is the sole active display) at
|
||||
/// the monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in
|
||||
/// the OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across
|
||||
/// preset changes; `#[serde(default)]` = off so existing `display-settings.json` files are
|
||||
/// untouched.
|
||||
#[serde(default)]
|
||||
pub ddc_power_off: bool,
|
||||
/// EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's
|
||||
/// duration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays
|
||||
/// disabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`
|
||||
/// isolate deactivated, plus — in ANY topology — external monitors that are connected but not
|
||||
/// part of the desktop (the standby TV that was never active, whose input auto-scan /
|
||||
/// instant-on HPD cycling re-probes the link every few seconds). Targets the same
|
||||
/// "connected-but-dark head" periodic-stutter class as [`Self::ddc_power_off`], but at the
|
||||
/// Windows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD
|
||||
/// re-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host
|
||||
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
|
||||
#[serde(default)]
|
||||
pub pnp_disable_monitors: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
fn default_max_displays() -> u32 {
|
||||
4
|
||||
}
|
||||
|
||||
impl Default for DisplayPolicy {
|
||||
fn default() -> Self {
|
||||
// Bit-for-bit today's behavior (the `default` preset expanded), so an unconfigured host reads
|
||||
// the same policy the Stage-0 call sites already produce.
|
||||
DisplayPolicy {
|
||||
version: 1,
|
||||
preset: Preset::Custom,
|
||||
keep_alive: KeepAlive::default(),
|
||||
topology: Topology::Auto,
|
||||
mode_conflict: ModeConflict::default(),
|
||||
identity: Identity::default(),
|
||||
layout: Layout::default(),
|
||||
max_displays: 4,
|
||||
game_session: GameSession::default(),
|
||||
ddc_power_off: false,
|
||||
pnp_disable_monitors: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call
|
||||
/// sites read, and what the mgmt API echoes as the "currently in force" policy. Pure output of
|
||||
/// [`DisplayPolicy::effective`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EffectivePolicy {
|
||||
pub keep_alive: KeepAlive,
|
||||
pub topology: Topology,
|
||||
pub mode_conflict: ModeConflict,
|
||||
pub identity: Identity,
|
||||
pub layout: Layout,
|
||||
pub max_displays: u32,
|
||||
}
|
||||
|
||||
impl DisplayPolicy {
|
||||
/// Resolve to the [`EffectivePolicy`]: a named preset expands to its bundle; `Custom` uses the
|
||||
/// explicit fields. Pure — the single source of truth shared by the preset docs and the runtime.
|
||||
pub fn effective(&self) -> EffectivePolicy {
|
||||
if let Some(mut e) = preset_fields(self.preset) {
|
||||
// A preset fixes the six behavior fields but honors an explicit manual layout table
|
||||
// (positions are data, not behavior — the `workstation` preset only sets the *mode*).
|
||||
if self.preset == Preset::Workstation && !self.layout.positions.is_empty() {
|
||||
e.layout.positions = self.layout.positions.clone();
|
||||
}
|
||||
e
|
||||
} else {
|
||||
EffectivePolicy {
|
||||
keep_alive: self.keep_alive,
|
||||
topology: self.topology,
|
||||
mode_conflict: self.mode_conflict,
|
||||
identity: self.identity,
|
||||
layout: self.layout.clone(),
|
||||
max_displays: self.max_displays,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp fields to their valid ranges (called on write). `max_displays` to `1..=16` (the
|
||||
/// pf-vdisplay connector ceiling / a sane Linux bound).
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.version = 1;
|
||||
self.max_displays = self.max_displays.clamp(1, 16);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectivePolicy {
|
||||
/// Build a persistable `Custom` [`DisplayPolicy`] that keeps THIS effective behavior but replaces
|
||||
/// the arrangement with a **manual** layout at `positions` — the `/display/layout` endpoint's
|
||||
/// transform, factored out pure so arranging displays stays orthogonal to the other axes and is
|
||||
/// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new
|
||||
/// layout — rule; a named preset would ignore them.)
|
||||
pub fn with_manual_layout(
|
||||
&self,
|
||||
positions: BTreeMap<String, Position>,
|
||||
game_session: GameSession,
|
||||
ddc_power_off: bool,
|
||||
pnp_disable_monitors: bool,
|
||||
) -> DisplayPolicy {
|
||||
DisplayPolicy {
|
||||
version: 1,
|
||||
preset: Preset::Custom,
|
||||
keep_alive: self.keep_alive,
|
||||
topology: self.topology,
|
||||
mode_conflict: self.mode_conflict,
|
||||
identity: self.identity,
|
||||
layout: Layout {
|
||||
mode: LayoutMode::Manual,
|
||||
positions,
|
||||
},
|
||||
max_displays: self.max_displays,
|
||||
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
|
||||
game_session,
|
||||
ddc_power_off,
|
||||
pnp_disable_monitors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The field bundle a named preset expands to; `None` for [`Preset::Custom`]. The single expansion
|
||||
/// table — the docs' preset table mirrors this and the `presets_match_doc` test guards the shape.
|
||||
pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> {
|
||||
let base = |keep_alive, topology, mode_conflict, identity, layout_mode| EffectivePolicy {
|
||||
keep_alive,
|
||||
topology,
|
||||
mode_conflict,
|
||||
identity,
|
||||
layout: Layout {
|
||||
mode: layout_mode,
|
||||
positions: BTreeMap::new(),
|
||||
},
|
||||
max_displays: 4,
|
||||
};
|
||||
Some(match preset {
|
||||
Preset::Custom => return None,
|
||||
Preset::Default => base(
|
||||
KeepAlive::Duration { seconds: 10 },
|
||||
Topology::Auto,
|
||||
ModeConflict::Separate,
|
||||
Identity::PerClient,
|
||||
LayoutMode::AutoRow,
|
||||
),
|
||||
Preset::GamingRig => base(
|
||||
KeepAlive::Forever,
|
||||
Topology::Exclusive,
|
||||
ModeConflict::Steal,
|
||||
Identity::PerClient,
|
||||
LayoutMode::AutoRow,
|
||||
),
|
||||
Preset::SharedDesktop => base(
|
||||
KeepAlive::Off,
|
||||
Topology::Extend,
|
||||
ModeConflict::Separate,
|
||||
Identity::PerClient,
|
||||
LayoutMode::AutoRow,
|
||||
),
|
||||
Preset::Hotdesk => base(
|
||||
KeepAlive::Duration { seconds: 300 },
|
||||
Topology::Exclusive,
|
||||
ModeConflict::Reject,
|
||||
Identity::PerClientMode,
|
||||
LayoutMode::AutoRow,
|
||||
),
|
||||
Preset::Workstation => base(
|
||||
KeepAlive::Duration { seconds: 300 },
|
||||
Topology::Exclusive,
|
||||
ModeConflict::Separate,
|
||||
Identity::PerClient,
|
||||
LayoutMode::Manual,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// The persisted policy store: the loaded file value (or `None` when no file exists) behind its
|
||||
/// JSON path. Mirrors [`pf_gpu::GpuPrefStore`] — private dir, temp-write + atomic rename,
|
||||
/// in-memory rollback if the disk write fails.
|
||||
pub struct DisplayPolicyStore {
|
||||
path: PathBuf,
|
||||
/// `Some` only when a valid `display-settings.json` was loaded / written — the "console has
|
||||
/// configured this host" signal that gates whether Stage-0 call sites override their historical
|
||||
/// env/default behavior.
|
||||
cur: Mutex<Option<DisplayPolicy>>,
|
||||
}
|
||||
|
||||
impl DisplayPolicyStore {
|
||||
/// Load from `path`. A missing file ⇒ unconfigured (`None`); a corrupt file ⇒ unconfigured with a
|
||||
/// warning (never fail host startup over a settings file).
|
||||
pub fn load_from(path: PathBuf) -> Self {
|
||||
let cur = match std::fs::read(&path) {
|
||||
Ok(bytes) => match serde_json::from_slice::<DisplayPolicy>(&bytes) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(),
|
||||
"display-settings.json unreadable — using built-in defaults: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(_) => None,
|
||||
};
|
||||
DisplayPolicyStore {
|
||||
path,
|
||||
cur: Mutex::new(cur),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored policy, or [`DisplayPolicy::default`] when unconfigured (for the mgmt GET).
|
||||
pub fn get(&self) -> DisplayPolicy {
|
||||
self.cur.lock().unwrap().clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The console-configured policy, or `None` when no settings file exists. Stage-0 call sites use
|
||||
/// this to decide whether to override their historical behavior (`None` ⇒ leave it untouched).
|
||||
pub fn configured(&self) -> Option<DisplayPolicy> {
|
||||
self.cur.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// The effective (preset-expanded) policy the console configured, or `None` when unconfigured.
|
||||
pub fn configured_effective(&self) -> Option<EffectivePolicy> {
|
||||
self.configured().map(|p| p.effective())
|
||||
}
|
||||
|
||||
/// The game-session routing axis (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to
|
||||
/// the preset — read directly off the stored policy (or the default `Auto` when unconfigured), so a
|
||||
/// preset selection never resets it.
|
||||
pub fn game_session(&self) -> GameSession {
|
||||
self.get().game_session
|
||||
}
|
||||
|
||||
/// The experimental DDC/CI panel-off axis — orthogonal to the preset (like
|
||||
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||
/// unconfigured).
|
||||
pub fn ddc_power_off(&self) -> bool {
|
||||
self.get().ddc_power_off
|
||||
}
|
||||
|
||||
/// The experimental PnP monitor-devnode-disable axis — orthogonal to the preset (like
|
||||
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||
/// unconfigured).
|
||||
pub fn pnp_disable_monitors(&self) -> bool {
|
||||
self.get().pnp_disable_monitors
|
||||
}
|
||||
|
||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
||||
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
||||
let policy = policy.sanitized();
|
||||
if let Some(dir) = self.path.parent() {
|
||||
pf_paths::create_private_dir(dir)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&policy)?)?;
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
*self.cur.lock().unwrap() = Some(policy);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide display-policy store (config-dir file), loaded once on first access — the same
|
||||
/// global-accessor shape as [`pf_gpu::prefs`], because display setup happens deep in the
|
||||
/// capture/vdisplay path where no app state is threaded.
|
||||
pub fn prefs() -> &'static DisplayPolicyStore {
|
||||
static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new();
|
||||
STORE.get_or_init(|| {
|
||||
DisplayPolicyStore::load_from(pf_paths::config_dir().join("display-settings.json"))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// User-defined custom presets (`<config>/display-presets.json`)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a
|
||||
/// built-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names
|
||||
/// and applies from the console.
|
||||
///
|
||||
/// Unlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in
|
||||
/// `<config>/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these
|
||||
/// fields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and
|
||||
/// the built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:
|
||||
/// editing or deleting a preset never mutates the running policy (re-apply to adopt a change).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CustomPreset {
|
||||
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
|
||||
pub id: String,
|
||||
/// User-facing name shown on the preset card; editable.
|
||||
pub name: String,
|
||||
/// The six display-behavior axes this preset applies (the same shape a built-in preset expands to).
|
||||
pub fields: EffectivePolicy,
|
||||
/// The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).
|
||||
/// A custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying
|
||||
/// one does set this axis.
|
||||
#[serde(default)]
|
||||
pub game_session: GameSession,
|
||||
}
|
||||
|
||||
/// Request body to create or replace a custom preset (no `id` — the host owns it).
|
||||
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||
pub struct CustomPresetInput {
|
||||
pub name: String,
|
||||
pub fields: EffectivePolicy,
|
||||
#[serde(default)]
|
||||
pub game_session: GameSession,
|
||||
}
|
||||
|
||||
fn custom_presets_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("display-presets.json")
|
||||
}
|
||||
|
||||
/// Clamp a saved preset's fields to their valid ranges — the same bounds [`DisplayPolicy::sanitized`]
|
||||
/// enforces, so a preset can never carry an out-of-range `max_displays` that a later apply would reject.
|
||||
fn sanitize_preset_fields(mut fields: EffectivePolicy) -> EffectivePolicy {
|
||||
fields.max_displays = fields.max_displays.clamp(1, 16);
|
||||
fields
|
||||
}
|
||||
|
||||
/// Load the saved custom presets (empty + non-fatal if the file is absent or malformed — a bad
|
||||
/// catalog never breaks the console's settings GET).
|
||||
pub fn load_custom_presets() -> Vec<CustomPreset> {
|
||||
match std::fs::read(custom_presets_path()) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "display-presets.json malformed — ignoring custom presets");
|
||||
Vec::new()
|
||||
}),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the catalog (private dir, temp-write + atomic rename — the [`DisplayPolicyStore::set`]
|
||||
/// discipline, so a crash mid-write never truncates it).
|
||||
fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> {
|
||||
let path = custom_presets_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
pf_paths::create_private_dir(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(presets)?)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 12 hex chars from the name + wall-clock nanos — collision-free in practice, no uuid dep (the
|
||||
/// [`crate::library`] custom-entry id scheme).
|
||||
fn new_preset_id(name: &str) -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
hex::encode(&Sha256::digest(format!("{name}:{nanos}").as_bytes())[..6])
|
||||
}
|
||||
|
||||
/// Create a custom preset, returning it with its assigned id.
|
||||
pub fn add_custom_preset(input: CustomPresetInput) -> Result<CustomPreset> {
|
||||
let mut presets = load_custom_presets();
|
||||
let preset = CustomPreset {
|
||||
id: new_preset_id(&input.name),
|
||||
name: input.name,
|
||||
fields: sanitize_preset_fields(input.fields),
|
||||
game_session: input.game_session,
|
||||
};
|
||||
presets.push(preset.clone());
|
||||
save_custom_presets(&presets)?;
|
||||
Ok(preset)
|
||||
}
|
||||
|
||||
/// Replace a custom preset's fields (id preserved). `None` ⇒ no preset with that id.
|
||||
pub fn update_custom_preset(id: &str, input: CustomPresetInput) -> Result<Option<CustomPreset>> {
|
||||
let mut presets = load_custom_presets();
|
||||
let Some(slot) = presets.iter_mut().find(|p| p.id == id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
slot.name = input.name;
|
||||
slot.fields = sanitize_preset_fields(input.fields);
|
||||
slot.game_session = input.game_session;
|
||||
let updated = slot.clone();
|
||||
save_custom_presets(&presets)?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
|
||||
/// Delete a custom preset. `false` ⇒ no preset with that id.
|
||||
pub fn delete_custom_preset(id: &str) -> Result<bool> {
|
||||
let mut presets = load_custom_presets();
|
||||
let before = presets.len();
|
||||
presets.retain(|p| p.id != id);
|
||||
if presets.len() == before {
|
||||
return Ok(false);
|
||||
}
|
||||
save_custom_presets(&presets)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_preset_serde_roundtrips_and_defaults_game_session() {
|
||||
let preset = CustomPreset {
|
||||
id: "abc123".into(),
|
||||
name: "My Rig".into(),
|
||||
fields: preset_fields(Preset::GamingRig).unwrap(),
|
||||
game_session: GameSession::Dedicated,
|
||||
};
|
||||
let json = serde_json::to_string(&preset).unwrap();
|
||||
assert_eq!(serde_json::from_str::<CustomPreset>(&json).unwrap(), preset);
|
||||
|
||||
// A catalog written before `game_session` existed still loads (defaults to `Auto`).
|
||||
let legacy: CustomPreset = serde_json::from_value(serde_json::json!({
|
||||
"id": "x",
|
||||
"name": "Legacy",
|
||||
"fields": serde_json::to_value(preset_fields(Preset::Default).unwrap()).unwrap(),
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(legacy.game_session, GameSession::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preset_fields_clamps_max_displays() {
|
||||
let mut f = preset_fields(Preset::Default).unwrap();
|
||||
f.max_displays = 999;
|
||||
assert_eq!(sanitize_preset_fields(f.clone()).max_displays, 16);
|
||||
f.max_displays = 0;
|
||||
assert_eq!(sanitize_preset_fields(f).max_displays, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_alive_serializes_tagged_on_mode() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(KeepAlive::Duration { seconds: 300 }).unwrap(),
|
||||
serde_json::json!({ "mode": "duration", "seconds": 300 })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(KeepAlive::Off).unwrap(),
|
||||
serde_json::json!({ "mode": "off" })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(KeepAlive::Forever).unwrap(),
|
||||
serde_json::json!({ "mode": "forever" })
|
||||
);
|
||||
// Round-trips.
|
||||
for k in [
|
||||
KeepAlive::Off,
|
||||
KeepAlive::Duration { seconds: 42 },
|
||||
KeepAlive::Forever,
|
||||
] {
|
||||
let s = serde_json::to_string(&k).unwrap();
|
||||
assert_eq!(serde_json::from_str::<KeepAlive>(&s).unwrap(), k);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_alive_linger_resolution() {
|
||||
assert_eq!(KeepAlive::Off.linger(), Linger::Immediate);
|
||||
assert_eq!(
|
||||
KeepAlive::Duration { seconds: 30 }.linger(),
|
||||
Linger::For(Duration::from_secs(30))
|
||||
);
|
||||
assert_eq!(KeepAlive::Forever.linger(), Linger::Forever);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_policy_is_todays_behavior() {
|
||||
let e = DisplayPolicy::default().effective();
|
||||
assert_eq!(e.keep_alive, KeepAlive::Duration { seconds: 10 });
|
||||
assert_eq!(e.topology, Topology::Auto);
|
||||
assert_eq!(e.mode_conflict, ModeConflict::Separate);
|
||||
assert_eq!(e.identity, Identity::PerClient);
|
||||
assert_eq!(e.layout.mode, LayoutMode::AutoRow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_uses_explicit_fields_presets_override_them() {
|
||||
// Custom: explicit fields flow through.
|
||||
let p = DisplayPolicy {
|
||||
preset: Preset::Custom,
|
||||
keep_alive: KeepAlive::Off,
|
||||
topology: Topology::Extend,
|
||||
..DisplayPolicy::default()
|
||||
};
|
||||
assert_eq!(p.effective().keep_alive, KeepAlive::Off);
|
||||
assert_eq!(p.effective().topology, Topology::Extend);
|
||||
|
||||
// A named preset ignores the explicit fields.
|
||||
let p = DisplayPolicy {
|
||||
preset: Preset::GamingRig,
|
||||
keep_alive: KeepAlive::Off, // ignored
|
||||
topology: Topology::Extend, // ignored
|
||||
..DisplayPolicy::default()
|
||||
};
|
||||
let e = p.effective();
|
||||
assert_eq!(e.keep_alive, KeepAlive::Forever);
|
||||
assert_eq!(e.topology, Topology::Exclusive);
|
||||
assert_eq!(e.mode_conflict, ModeConflict::Steal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workstation_preset_keeps_manual_layout_positions() {
|
||||
let mut positions = BTreeMap::new();
|
||||
positions.insert("1".to_string(), Position { x: 2560, y: 0 });
|
||||
let p = DisplayPolicy {
|
||||
preset: Preset::Workstation,
|
||||
layout: Layout {
|
||||
mode: LayoutMode::AutoRow, // preset forces Manual regardless
|
||||
positions,
|
||||
},
|
||||
..DisplayPolicy::default()
|
||||
};
|
||||
let e = p.effective();
|
||||
assert_eq!(e.layout.mode, LayoutMode::Manual);
|
||||
assert_eq!(
|
||||
e.layout.positions.get("1"),
|
||||
Some(&Position { x: 2560, y: 0 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_expands() {
|
||||
for preset in [
|
||||
Preset::Default,
|
||||
Preset::GamingRig,
|
||||
Preset::SharedDesktop,
|
||||
Preset::Hotdesk,
|
||||
Preset::Workstation,
|
||||
] {
|
||||
assert!(preset_fields(preset).is_some(), "{preset:?} must expand");
|
||||
}
|
||||
assert!(preset_fields(Preset::Custom).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_clamps_max_displays_and_pins_version() {
|
||||
let p = DisplayPolicy {
|
||||
version: 99,
|
||||
max_displays: 0,
|
||||
..DisplayPolicy::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.version, 1);
|
||||
assert_eq!(p.max_displays, 1);
|
||||
let p = DisplayPolicy {
|
||||
max_displays: 999,
|
||||
..DisplayPolicy::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.max_displays, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_manual_layout_preserves_behavior_and_sets_positions() {
|
||||
// Start from a preset's effective behavior (workstation: 5-min linger, exclusive, per-client).
|
||||
let eff = DisplayPolicy {
|
||||
preset: Preset::Workstation,
|
||||
..DisplayPolicy::default()
|
||||
}
|
||||
.effective();
|
||||
let mut positions = BTreeMap::new();
|
||||
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
|
||||
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
|
||||
// the transform.
|
||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||
assert!(p.ddc_power_off);
|
||||
assert!(p.pnp_disable_monitors);
|
||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||
assert_eq!(p.preset, Preset::Custom);
|
||||
// …every other behavior axis is preserved verbatim…
|
||||
assert_eq!(p.keep_alive, eff.keep_alive);
|
||||
assert_eq!(p.topology, eff.topology);
|
||||
assert_eq!(p.mode_conflict, eff.mode_conflict);
|
||||
assert_eq!(p.identity, eff.identity);
|
||||
assert_eq!(p.max_displays, eff.max_displays);
|
||||
// …and the arrangement is the manual layout we asked for, surviving the effective round-trip.
|
||||
let e2 = p.effective();
|
||||
assert_eq!(e2.layout.mode, LayoutMode::Manual);
|
||||
let want = Position { x: 2560, y: 0 };
|
||||
assert_eq!(e2.layout.positions.get("7"), Some(&want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_json_fills_defaults() {
|
||||
// A hand-written file with only a couple of fields loads, the rest defaulting.
|
||||
let p: DisplayPolicy =
|
||||
serde_json::from_str(r#"{ "preset": "custom", "max_displays": 2 }"#).unwrap();
|
||||
assert_eq!(p.max_displays, 2);
|
||||
assert_eq!(p.keep_alive, KeepAlive::default());
|
||||
assert_eq!(p.topology, Topology::Auto);
|
||||
assert_eq!(p.version, 1);
|
||||
// A file written before the experimental DDC/PnP axes existed defaults them OFF.
|
||||
assert!(!p.ddc_power_off);
|
||||
assert!(!p.pnp_disable_monitors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_roundtrips_and_gates_on_file_presence() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-disp-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("display-settings.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let store = DisplayPolicyStore::load_from(path.clone());
|
||||
// Unconfigured: get() yields defaults, configured() is None.
|
||||
assert!(store.configured().is_none());
|
||||
assert_eq!(store.get(), DisplayPolicy::default());
|
||||
|
||||
// After a write the file gates flip to configured.
|
||||
let want = DisplayPolicy {
|
||||
preset: Preset::SharedDesktop,
|
||||
..DisplayPolicy::default()
|
||||
};
|
||||
store.set(want.clone()).unwrap();
|
||||
assert_eq!(
|
||||
store.configured().as_ref().map(|p| p.preset),
|
||||
Some(Preset::SharedDesktop)
|
||||
);
|
||||
assert_eq!(
|
||||
store.configured_effective().unwrap().keep_alive,
|
||||
KeepAlive::Off
|
||||
);
|
||||
|
||||
// A fresh store reading the same path sees the persisted value.
|
||||
let reopened = DisplayPolicyStore::load_from(path.clone());
|
||||
assert_eq!(reopened.configured().unwrap().preset, Preset::SharedDesktop);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,262 +0,0 @@
|
||||
//! Gamescope-session routing (plan §W3 — carved out of [`super`]): mode selection
|
||||
//! ([`pick_gamescope_mode`]), input-env routing ([`apply_input_env`]), dedicated-game-session
|
||||
//! decisions/launch ([`wants_dedicated_game_session`], [`launch_into_gamescope_session`]), and the
|
||||
//! managed-session restore workers.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`],
|
||||
/// written into the env knobs `GamescopeDisplay::create` dispatches on.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GamescopeMode {
|
||||
/// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode.
|
||||
Managed,
|
||||
/// Attach to an already-running gamescope (capture + inject, no lifecycle ownership).
|
||||
Attach,
|
||||
/// Bare-spawn a headless gamescope per session, nesting the session's launch command.
|
||||
Spawn,
|
||||
}
|
||||
|
||||
/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters):
|
||||
/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an
|
||||
/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box
|
||||
/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code
|
||||
/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session);
|
||||
/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final
|
||||
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pick_gamescope_mode(
|
||||
dedicated_launch: bool,
|
||||
force_managed: bool,
|
||||
attach_env: bool,
|
||||
node_env: bool,
|
||||
session_env: bool,
|
||||
managed_infra: bool,
|
||||
foreign_gamescope: bool,
|
||||
) -> GamescopeMode {
|
||||
if force_managed {
|
||||
GamescopeMode::Managed
|
||||
} else if attach_env || node_env {
|
||||
GamescopeMode::Attach
|
||||
} else if dedicated_launch {
|
||||
// A dedicated game session always spawns its own headless gamescope at the client's mode,
|
||||
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
|
||||
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
|
||||
GamescopeMode::Spawn
|
||||
} else if session_env || managed_infra {
|
||||
GamescopeMode::Managed
|
||||
} else if foreign_gamescope {
|
||||
GamescopeMode::Attach
|
||||
} else {
|
||||
GamescopeMode::Spawn
|
||||
}
|
||||
}
|
||||
|
||||
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
|
||||
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
|
||||
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
|
||||
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
|
||||
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
|
||||
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
|
||||
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
|
||||
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
||||
/// forces managed over all of it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let backend = match chosen {
|
||||
Compositor::Gamescope => "gamescope",
|
||||
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
|
||||
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
|
||||
Compositor::Kwin => "kwin",
|
||||
// GNOME has neither fake_input nor the wlr protocols → RemoteDesktop portal via libei.
|
||||
Compositor::Mutter => "libei",
|
||||
// Hyprland kept `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` (D4) — same wlr
|
||||
// injector as sway/river, no code change.
|
||||
Compositor::Wlroots | Compositor::Hyprland => "wlr",
|
||||
};
|
||||
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
||||
if chosen == Compositor::Gamescope {
|
||||
let mode = pick_gamescope_mode(
|
||||
dedicated_launch,
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(),
|
||||
gamescope::managed_session_available(),
|
||||
gamescope::foreign_gamescope_running(),
|
||||
);
|
||||
tracing::info!(?mode, "gamescope sub-mode");
|
||||
match mode {
|
||||
GamescopeMode::Attach => {
|
||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
|
||||
}
|
||||
}
|
||||
GamescopeMode::Managed => {
|
||||
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
|
||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
|
||||
}
|
||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||
}
|
||||
GamescopeMode::Spawn => {
|
||||
// Bare spawn: `create` must fall through to the spawn path, so neither knob may
|
||||
// linger from an earlier connect's managed/attach selection.
|
||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
|
||||
|
||||
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
|
||||
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
|
||||
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
|
||||
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
|
||||
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
|
||||
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
|
||||
use policy::GameSession;
|
||||
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
|
||||
return false;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if gamescope::is_available() {
|
||||
true
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false // Windows: a launching session opens into the one desktop (no gamescope)
|
||||
}
|
||||
}
|
||||
|
||||
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
||||
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
||||
/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope
|
||||
/// sub-mode (the env knobs are that resolution's output).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn launch_is_nested(compositor: Compositor) -> bool {
|
||||
compositor == Compositor::Gamescope
|
||||
&& with_env_lock(|| {
|
||||
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
|
||||
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
|
||||
})
|
||||
}
|
||||
|
||||
/// Launch `cmd` into the live gamescope session (managed/attach — see
|
||||
/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private
|
||||
/// backend module.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
||||
gamescope::launch_into_session(cmd)
|
||||
}
|
||||
|
||||
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
||||
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
||||
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
||||
/// exit (review #4/#8). Always `false` off Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn dedicated_game_exited(node_id: u32) -> bool {
|
||||
gamescope::game_session_exited(node_id)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn dedicated_game_exited(_node_id: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn cancel_pending_tv_restore() {
|
||||
gamescope::cancel_pending_restore();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn cancel_pending_tv_restore() {}
|
||||
|
||||
/// Call when a client session ends: if the host-managed gamescope path took over a box's autologin
|
||||
/// gaming session (stopped its single-instance Steam to stream at the client's mode), **schedule** a
|
||||
/// debounced restore so the TV returns to gaming mode — unless a client reconnects within the window
|
||||
/// (which reuses the warm session, avoiding the per-connect gamescope stop/relaunch that leaked GPU
|
||||
/// context on F44). No-op on other compositors / when nothing was taken. Needs [`start_restore_worker`]
|
||||
/// running to actually fire.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn restore_managed_session() {
|
||||
gamescope::schedule_restore_tv_session();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_managed_session() {}
|
||||
|
||||
/// Start the host-lifetime worker that fires debounced [`restore_managed_session`] restores once a
|
||||
/// client has been gone long enough. Hold the returned handle for the host's lifetime; dropping it
|
||||
/// stops the worker. Call once from `serve()`.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
gamescope::start_restore_worker()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
std::sync::Arc::new(())
|
||||
}
|
||||
|
||||
/// Recover a stranded TV takeover from a crashed previous host instance
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
|
||||
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn restore_takeover_on_startup() {
|
||||
gamescope::restore_takeover_on_startup();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_takeover_on_startup() {}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gamescope_mode_ladder() {
|
||||
use GamescopeMode::*;
|
||||
let pick = pick_gamescope_mode;
|
||||
// (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
||||
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
||||
assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
|
||||
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
||||
assert_eq!(
|
||||
pick(false, false, false, false, false, true, false),
|
||||
Managed
|
||||
);
|
||||
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
|
||||
// Foreign gamescope on an infra-less box: attach and mirror it.
|
||||
assert_eq!(pick(false, false, false, false, false, false, true), Attach);
|
||||
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
||||
assert_eq!(
|
||||
pick(false, false, false, false, true, false, false),
|
||||
Managed
|
||||
);
|
||||
// Explicit attach/node wins over infra…
|
||||
assert_eq!(pick(false, false, true, false, false, true, false), Attach);
|
||||
assert_eq!(pick(false, false, false, true, true, true, false), Attach);
|
||||
// …and force-managed wins over everything.
|
||||
assert_eq!(pick(false, true, true, true, false, false, false), Managed);
|
||||
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
|
||||
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
|
||||
// …but the explicit operator overrides still win over dedicated.
|
||||
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
|
||||
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
|
||||
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
|
||||
}
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
//! Live graphical-session detection + session-epoch + process-env retargeting (plan §W3 — the
|
||||
//! self-contained subsystem carved out of [`super`]). Detects the active compositor/session
|
||||
//! ([`detect_active_session`]), tracks the session epoch so pooled displays never outlive their
|
||||
//! compositor instance, and retargets the process env at the live session ([`apply_session_env`],
|
||||
//! [`settle_desktop_portal`]) under `super::ENV_LOCK`.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The **session epoch** — bumped whenever session detection observes a different compositor
|
||||
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
|
||||
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
|
||||
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
|
||||
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
|
||||
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
|
||||
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
|
||||
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
|
||||
pub fn session_epoch() -> u64 {
|
||||
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
|
||||
/// instance (kind change, or same-kind new PID). Returns the new value.
|
||||
pub fn bump_session_epoch() -> u64 {
|
||||
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
|
||||
}
|
||||
|
||||
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
|
||||
/// genuine instance change from a stable re-detect.
|
||||
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
|
||||
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
|
||||
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
|
||||
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
|
||||
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
|
||||
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
|
||||
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
|
||||
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
|
||||
pub fn observe_session_instance(active: &ActiveSession) {
|
||||
let cur = (active.kind, active.compositor_pid);
|
||||
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(prev) = *last {
|
||||
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
|
||||
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
|
||||
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
|
||||
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
|
||||
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
|
||||
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
|
||||
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
|
||||
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
|
||||
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
|
||||
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
|
||||
if is_desktop_kind(prev.0) {
|
||||
if let Some(old) = compositor_for_kind(prev.0) {
|
||||
registry::invalidate_backend(old.id());
|
||||
}
|
||||
}
|
||||
let epoch = bump_session_epoch();
|
||||
tracing::info!(
|
||||
from = ?prev.0,
|
||||
to = ?cur.0,
|
||||
epoch,
|
||||
"desktop compositor instance changed — session epoch bumped"
|
||||
);
|
||||
}
|
||||
}
|
||||
*last = Some(cur);
|
||||
}
|
||||
|
||||
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
||||
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
||||
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
||||
fn is_desktop_kind(kind: ActiveKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
ActiveKind::DesktopKde
|
||||
| ActiveKind::DesktopGnome
|
||||
| ActiveKind::DesktopWlroots
|
||||
| ActiveKind::DesktopHyprland
|
||||
)
|
||||
}
|
||||
|
||||
/// The kind of graphical session live for our uid *right now* — the basis for per-connect backend
|
||||
/// selection on a box that flips between Steam Gaming Mode and a KDE/GNOME desktop (Bazzite,
|
||||
/// SteamOS). Detected by probing which compositor process is actually running, not by a static
|
||||
/// env var, so the host follows the box as the user switches sessions.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ActiveKind {
|
||||
/// A `gamescope` session is live (Steam Gaming Mode / `gamescope-session-plus`).
|
||||
Gaming,
|
||||
/// A KWin / Plasma desktop is live.
|
||||
DesktopKde,
|
||||
/// A GNOME / Mutter desktop is live.
|
||||
DesktopGnome,
|
||||
/// A wlroots-proper (Sway / River) desktop is live.
|
||||
DesktopWlroots,
|
||||
/// A Hyprland desktop is live (distinct from [`DesktopWlroots`](ActiveKind::DesktopWlroots):
|
||||
/// its own `hyprctl` IPC + xdph portal, though it shares the wlr virtual-input path).
|
||||
DesktopHyprland,
|
||||
/// No recognized graphical session is running for our uid.
|
||||
None,
|
||||
}
|
||||
|
||||
/// The session environment that points a backend at the [detected](detect_active_session) active
|
||||
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
|
||||
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
|
||||
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
|
||||
/// per connect and every backend that reads them then opens against the live session.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SessionEnv {
|
||||
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
|
||||
/// PipeWire-node / D-Bus driven and don't talk Wayland to us).
|
||||
pub wayland_display: Option<String>,
|
||||
/// `/run/user/<uid>` — the trustworthy anchor (the default PipeWire daemon + bus live here).
|
||||
pub xdg_runtime_dir: String,
|
||||
/// `DBUS_SESSION_BUS_ADDRESS` (defaults to `unix:path=<runtime>/bus`).
|
||||
pub dbus_session_bus_address: String,
|
||||
/// `XDG_CURRENT_DESKTOP` to advertise (KDE/GNOME/sway/Hyprland/gamescope) — drives portal/EIS
|
||||
/// routing (xdph keys its Hyprland-specific behavior off `Hyprland`).
|
||||
pub xdg_current_desktop: Option<String>,
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
|
||||
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
|
||||
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
|
||||
/// session env (unlike sway's `SWAYSOCK`). `None` for every other compositor.
|
||||
pub hyprland_signature: Option<String>,
|
||||
}
|
||||
|
||||
/// The live session: its [`ActiveKind`] plus the [`SessionEnv`] to target it.
|
||||
pub struct ActiveSession {
|
||||
pub kind: ActiveKind,
|
||||
pub env: SessionEnv,
|
||||
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
|
||||
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
|
||||
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
|
||||
pub compositor_pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl ActiveSession {
|
||||
/// A "nothing live" result carrying just the runtime-dir anchor.
|
||||
fn none() -> ActiveSession {
|
||||
ActiveSession {
|
||||
kind: ActiveKind::None,
|
||||
env: SessionEnv {
|
||||
xdg_runtime_dir: default_runtime_dir(),
|
||||
dbus_session_bus_address: default_bus(&default_runtime_dir()),
|
||||
..Default::default()
|
||||
},
|
||||
compositor_pid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The concrete backend that drives a given live-session kind. `None` for [`ActiveKind::None`].
|
||||
pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
|
||||
match kind {
|
||||
ActiveKind::Gaming => Some(Compositor::Gamescope),
|
||||
ActiveKind::DesktopKde => Some(Compositor::Kwin),
|
||||
ActiveKind::DesktopGnome => Some(Compositor::Mutter),
|
||||
ActiveKind::DesktopWlroots => Some(Compositor::Wlroots),
|
||||
ActiveKind::DesktopHyprland => Some(Compositor::Hyprland),
|
||||
ActiveKind::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn default_runtime_dir() -> String {
|
||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
|
||||
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
format!("/run/user/{uid}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn default_runtime_dir() -> String {
|
||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_default()
|
||||
}
|
||||
|
||||
fn default_bus(runtime: &str) -> String {
|
||||
std::env::var("DBUS_SESSION_BUS_ADDRESS").unwrap_or_else(|_| format!("unix:path={runtime}/bus"))
|
||||
}
|
||||
|
||||
/// Detect the graphical session live for our uid right now (cheap, side-effect-free: a `/proc`
|
||||
/// scan plus a runtime-dir socket scan — well under the handshake timeout). The authority is the
|
||||
/// running compositor process; a desktop compositor outranks a lingering gamescope. Used to route
|
||||
/// each connect to the correct backend, and to derive the [`SessionEnv`] that targets it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn detect_active_session() -> ActiveSession {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
|
||||
// it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let xdg_runtime_dir = default_runtime_dir();
|
||||
let dbus = default_bus(&xdg_runtime_dir);
|
||||
|
||||
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
|
||||
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
|
||||
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
|
||||
let mut kind = ActiveKind::None;
|
||||
let mut best = 0u8;
|
||||
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
|
||||
// session epoch (A4), not just a kind change.
|
||||
let mut winning_pid: Option<u32> = None;
|
||||
if let Ok(entries) = std::fs::read_dir("/proc") {
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let pid_path = e.path();
|
||||
let Ok(md) = std::fs::metadata(&pid_path) else {
|
||||
continue;
|
||||
};
|
||||
if md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
|
||||
continue;
|
||||
};
|
||||
let (k, prio) = match comm.trim() {
|
||||
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
|
||||
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
|
||||
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
|
||||
// Hyprland is its own backend (hyprctl + xdph) — split it out of the sway/river
|
||||
// wlroots-proper family (design/hyprland-support.md D1).
|
||||
"Hyprland" | "hyprland" => (ActiveKind::DesktopHyprland, 4),
|
||||
"sway" | "river" => (ActiveKind::DesktopWlroots, 4),
|
||||
_ => continue,
|
||||
};
|
||||
let pid = name.parse::<u32>().ok();
|
||||
if prio > best {
|
||||
best = prio;
|
||||
kind = k;
|
||||
winning_pid = pid;
|
||||
} else if prio == best {
|
||||
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
|
||||
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
|
||||
// `/proc` enumeration order — which `observe_session_instance` would misread as a
|
||||
// compositor restart and tear a live display down (re-review low-severity note).
|
||||
if let (Some(p), Some(w)) = (pid, winning_pid) {
|
||||
if p < w {
|
||||
kind = k;
|
||||
winning_pid = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wayland-protocol backends (KWin, wlroots, Hyprland) need the live socket for input (the wlr
|
||||
// virtual pointer/keyboard client connects to it); Gaming-attach and Mutter are node/D-Bus
|
||||
// driven and don't.
|
||||
let wayland_display = match kind {
|
||||
ActiveKind::DesktopKde | ActiveKind::DesktopWlroots | ActiveKind::DesktopHyprland => {
|
||||
find_wayland_socket(&xdg_runtime_dir, uid)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let xdg_current_desktop = match kind {
|
||||
ActiveKind::DesktopKde => Some("KDE".to_string()),
|
||||
ActiveKind::DesktopGnome => Some("GNOME".to_string()),
|
||||
ActiveKind::DesktopWlroots => Some("sway".to_string()),
|
||||
// G4: advertise the real desktop so portal routing (portals.conf `[Hyprland]`) and xdph's
|
||||
// own Hyprland checks work — NOT the old blanket `sway`.
|
||||
ActiveKind::DesktopHyprland => Some("Hyprland".to_string()),
|
||||
ActiveKind::Gaming => Some("gamescope".to_string()),
|
||||
ActiveKind::None => None,
|
||||
};
|
||||
// Discover the Hyprland instance signature so `hyprctl` can reach the compositor even when the
|
||||
// host runs as a systemd `--user` service that never inherited the session env.
|
||||
let hyprland_signature = match kind {
|
||||
ActiveKind::DesktopHyprland => find_hypr_signature(&xdg_runtime_dir, uid),
|
||||
_ => None,
|
||||
};
|
||||
ActiveSession {
|
||||
kind,
|
||||
env: SessionEnv {
|
||||
wayland_display,
|
||||
xdg_runtime_dir,
|
||||
dbus_session_bus_address: dbus,
|
||||
xdg_current_desktop,
|
||||
hyprland_signature,
|
||||
},
|
||||
compositor_pid: winning_pid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the live Hyprland instance signature (`HYPRLAND_INSTANCE_SIGNATURE`) for our uid. Trust a
|
||||
/// valid inherited value first (the host launched inside the session); otherwise pick the
|
||||
/// newest-mtime instance directory under `$XDG_RUNTIME_DIR/hypr/` that we own and that still has a
|
||||
/// live `.socket.sock` — the same "newest wins" heuristic as [`find_wayland_socket`]. A desktop
|
||||
/// normally exposes exactly one. (Phase-2 refinement: match the instance to `compositor_pid` via
|
||||
/// `hyprctl instances` when several coexist — `design/hyprland-support.md` §Phase-1.1.)
|
||||
#[cfg(target_os = "linux")]
|
||||
fn find_hypr_signature(runtime: &str, uid: u32) -> Option<String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let hypr = std::path::Path::new(runtime).join("hypr");
|
||||
if let Ok(sig) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
||||
if !sig.is_empty() && hypr.join(&sig).join(".socket.sock").exists() {
|
||||
return Some(sig);
|
||||
}
|
||||
}
|
||||
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||
for e in std::fs::read_dir(&hypr).ok()?.flatten() {
|
||||
let Ok(md) = e.metadata() else { continue };
|
||||
if !md.is_dir() || md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
if !e.path().join(".socket.sock").exists() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||
cands.push((mtime, name));
|
||||
}
|
||||
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
||||
cands.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn detect_active_session() -> ActiveSession {
|
||||
ActiveSession::none()
|
||||
}
|
||||
|
||||
/// Find the live `wayland-*` socket in `runtime` for our uid (skipping `.lock` sidecars). Trust a
|
||||
/// valid inherited `WAYLAND_DISPLAY` first; otherwise take the newest-mtime socket we own (a
|
||||
/// desktop session normally exposes exactly one).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if let Ok(w) = std::env::var("WAYLAND_DISPLAY") {
|
||||
if !w.is_empty() {
|
||||
let p = if w.starts_with('/') {
|
||||
std::path::PathBuf::from(&w)
|
||||
} else {
|
||||
std::path::Path::new(runtime).join(&w)
|
||||
};
|
||||
if p.exists() {
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cands: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||
for e in std::fs::read_dir(runtime).ok()?.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !name.starts_with("wayland-") || name.ends_with(".lock") {
|
||||
continue;
|
||||
}
|
||||
let Ok(md) = e.metadata() else { continue };
|
||||
if md.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||||
cands.push((mtime, name));
|
||||
}
|
||||
cands.sort_by_key(|(m, _)| std::cmp::Reverse(*m));
|
||||
cands.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
|
||||
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
||||
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
||||
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
||||
/// re-applies.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_session_env(active: &ActiveSession) {
|
||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let e = &active.env;
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
|
||||
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
|
||||
if let Some(w) = &e.wayland_display {
|
||||
std::env::set_var("WAYLAND_DISPLAY", w);
|
||||
}
|
||||
if let Some(d) = &e.xdg_current_desktop {
|
||||
std::env::set_var("XDG_CURRENT_DESKTOP", d);
|
||||
}
|
||||
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
|
||||
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
|
||||
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
|
||||
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
|
||||
match &e.hyprland_signature {
|
||||
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
|
||||
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||
}
|
||||
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
|
||||
// connect's retarget, and the availability probes read them: after a gnome-shell crash
|
||||
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
|
||||
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
|
||||
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
|
||||
// graphical session" handshake error. Clear them so `available()` reports the truth and the
|
||||
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
|
||||
if active.kind == ActiveKind::None {
|
||||
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||
std::env::remove_var("WAYLAND_DISPLAY");
|
||||
}
|
||||
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
|
||||
// [`effective_topology`] directly at create time — the console policy, else the legacy
|
||||
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
|
||||
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
|
||||
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn apply_session_env(_active: &ActiveSession) {}
|
||||
|
||||
/// Fire the operator's session-recovery hook (`PUNKTFUNK_RECOVER_SESSION_CMD`) because a client
|
||||
/// connected while NO graphical session is live for this uid — the state a compositor crash
|
||||
/// leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login only fires once per boot,
|
||||
/// so the box would otherwise sit headless until a walk-up login or a reboot). The command runs
|
||||
/// detached via `sh -c` (typically a display-manager restart — see the config docs) and is
|
||||
/// debounced to one launch per minute so a retrying client can't stack restarts. Returns whether
|
||||
/// a recovery is underway (just launched, or launched within the debounce window), letting the
|
||||
/// handshake error tell the client to simply retry.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn try_recover_session() -> bool {
|
||||
let Some(cmd) = pf_host_config::config().recover_session_cmd.clone() else {
|
||||
return false;
|
||||
};
|
||||
static LAST_LAUNCH: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
|
||||
const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
let mut last = LAST_LAUNCH.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.is_some_and(|t| t.elapsed() < DEBOUNCE) {
|
||||
return true; // a launch is already in flight — the retry lands in the recovered session
|
||||
}
|
||||
match std::process::Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
*last = Some(std::time::Instant::now());
|
||||
tracing::warn!(cmd = %cmd,
|
||||
"no live graphical session — launched the operator's session-recovery command");
|
||||
// Reap off-thread so the finished child never lingers as a zombie.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(cmd = %cmd, error = %e,
|
||||
"session-recovery command failed to launch");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn try_recover_session() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// On a **mid-stream** switch to a desktop, the xdg-desktop-portal (D-Bus-activated) and the systemd
|
||||
/// `--user` environment can still point at the OLD session, so the host's RemoteDesktop portal opens
|
||||
/// against a half-stale env — it accepts events but they don't reach the compositor until a
|
||||
/// reconnect. Push the live session env into the systemd/D-Bus activation environment and (for KWin,
|
||||
/// whose input rides the xdg RemoteDesktop portal) restart the portal so it re-reads it — the same
|
||||
/// settling a fresh desktop login does. Best-effort; mirrors the wlroots portal restart. GNOME uses
|
||||
/// Mutter's *direct* EIS (no xdg portal), so it only needs the env push.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn settle_desktop_portal(chosen: Compositor) {
|
||||
const VARS: &[&str] = &[
|
||||
"WAYLAND_DISPLAY",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_RUNTIME_DIR",
|
||||
];
|
||||
// Push our (correct) env into the systemd --user manager + the D-Bus activation environment so a
|
||||
// re-activated portal/backend inherits the live session.
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "import-environment"])
|
||||
.args(VARS)
|
||||
.status();
|
||||
let _ = std::process::Command::new("dbus-update-activation-environment")
|
||||
.arg("--systemd")
|
||||
.args(VARS)
|
||||
.status();
|
||||
// KWin input goes through the xdg RemoteDesktop portal; the frontend routes RemoteDesktop to a
|
||||
// backend by its OWN startup XDG_CURRENT_DESKTOP, so restart it (+ the KDE backend) to re-read
|
||||
// the now-live session, then let it settle before the injector reopens against it.
|
||||
if chosen == Compositor::Kwin {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"try-restart",
|
||||
"xdg-desktop-portal-kde.service",
|
||||
"xdg-desktop-portal.service",
|
||||
])
|
||||
.status();
|
||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
}
|
||||
// Hyprland capture rides the xdg ScreenCast portal serviced by xdph (G5): on a mid-stream switch
|
||||
// xdph may still hold the old session's Wayland/instance env, so restart it (+ the frontend) to
|
||||
// re-read the now-live session, mirroring the KWin settling above.
|
||||
if chosen == Compositor::Hyprland {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"try-restart",
|
||||
"xdg-desktop-portal-hyprland.service",
|
||||
"xdg-desktop-portal.service",
|
||||
])
|
||||
.status();
|
||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||
}
|
||||
tracing::info!(
|
||||
compositor = chosen.id(),
|
||||
"settled desktop portal env for the switched-to session"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn settle_desktop_portal(_chosen: Compositor) {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
//! The backend-specific virtual-display **seam** (SudoVDA vs pf-vdisplay), carved out of the manager
|
||||
//! (plan §W3): the REMOVE-key type, the `add_monitor` reply, and the IOCTL trait. This is the ONLY
|
||||
//! thing that differs between the two Windows backends — the refcount machine, linger, pinger, and
|
||||
//! CCD/GDI glue are all backend-neutral in [`super::VirtualDisplayManager`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
|
||||
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum MonitorKey {
|
||||
Guid(windows::core::GUID),
|
||||
Session(u64),
|
||||
}
|
||||
|
||||
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the
|
||||
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target) + the monitor id the
|
||||
/// driver actually resolved (the per-client stable id when honored; diagnostics on the slot).
|
||||
pub(crate) struct AddedMonitor {
|
||||
pub key: MonitorKey,
|
||||
pub target_id: u32,
|
||||
pub luid: LUID,
|
||||
pub wudf_pid: u32,
|
||||
pub resolved_monitor_id: u32,
|
||||
}
|
||||
|
||||
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
|
||||
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
|
||||
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
|
||||
/// `&'static` singleton reached from the pinger + linger threads.
|
||||
pub(crate) trait VdisplayDriver: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
/// Find + open the control device, validate it (version handshake), and read the watchdog
|
||||
/// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s
|
||||
/// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired)
|
||||
/// must NOT, since sessions this process still considers live may be racing it. Returns the
|
||||
/// owned handle + watchdog seconds.
|
||||
///
|
||||
/// # Safety
|
||||
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
|
||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
|
||||
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
||||
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
|
||||
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
|
||||
/// driver's built-in defaults). Returns the REMOVE key + target id + the IddCx DISPLAY adapter
|
||||
/// LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the
|
||||
/// driver reports its render adapter only in the shared frame header).
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle from [`open`](Self::open).
|
||||
unsafe fn add_monitor(
|
||||
&self,
|
||||
dev: HANDLE,
|
||||
mode: Mode,
|
||||
render_luid: Option<LUID>,
|
||||
preferred_monitor_id: u32,
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
) -> Result<AddedMonitor>;
|
||||
/// REMOVE the monitor identified by `key`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
|
||||
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! The cross-process single-instance guard for pf-vdisplay management (plan §W3, carved out of the
|
||||
//! manager). A named mutex makes a SECOND host process fail its vdisplay open loudly instead of firing
|
||||
//! `IOCTL_CLEAR_ALL` and razing the live host's monitors mid-stream.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The held single-instance mutex (`None` until claimed). Process-global — not per-manager — so the
|
||||
/// serve path can claim it EAGERLY at startup, before any session opens the backend: the claim is
|
||||
/// first-comer-wins, and a lazily-claiming service could otherwise lose its own machine's driver to
|
||||
/// a stray second host started while the service sat idle (observed on-glass). A failed claim is NOT
|
||||
/// memoized: once the other instance exits, the next attempt succeeds.
|
||||
static INSTANCE: Mutex<Option<OwnedHandle>> = Mutex::new(None);
|
||||
|
||||
/// Claim (or re-verify) the cross-process single-instance guard. Idempotent; retries after failure.
|
||||
pub(super) fn claim_instance() -> Result<()> {
|
||||
let mut g = INSTANCE.lock().unwrap();
|
||||
if g.is_none() {
|
||||
*g = Some(acquire_single_instance()?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Eager startup claim for the serve/service path (Windows): reserves this process as THE
|
||||
/// pf-vdisplay manager before any client connects. Failure is a loud warning, not fatal — sessions
|
||||
/// then fail with the same clear in-use error until the other instance exits.
|
||||
pub(crate) fn claim_instance_eagerly() {
|
||||
if let Err(e) = claim_instance() {
|
||||
tracing::warn!("pf-vdisplay single-instance claim failed at startup: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The cross-process single-instance guard for pf-vdisplay management. A SECOND host process's
|
||||
/// first device open used to fire `IOCTL_CLEAR_ALL` and raze the live host's monitors mid-stream —
|
||||
/// an admin footgun (run `punktfunk-host serve` while the SCM service streams), masked afterwards
|
||||
/// because both processes' pings satisfy the shared driver watchdog. The named mutex makes the
|
||||
/// second process fail its vdisplay open LOUDLY instead. Held, never released, for the process
|
||||
/// lifetime; the OS reclaims it (and frees the name) when the process exits, however it exits.
|
||||
fn acquire_single_instance() -> Result<OwnedHandle> {
|
||||
const IN_USE: &str = "another punktfunk-host process is already managing pf-vdisplay on this \
|
||||
machine — refusing to touch the driver (a second manager's startup CLEAR_ALL would raze \
|
||||
the live host's monitors mid-stream). Stop the other instance (e.g. `punktfunk-host \
|
||||
service stop`) first.";
|
||||
// SAFETY: plain FFI create of a named mutex; the returned handle (checked) is solely owned by
|
||||
// the `OwnedHandle`, and `GetLastError` is read immediately after the create — the documented
|
||||
// ERROR_ALREADY_EXISTS protocol for pre-existing named objects.
|
||||
unsafe {
|
||||
let h = match CreateMutexW(None, false, w!("Global\\punktfunk-vdisplay-manager")) {
|
||||
Ok(h) => h,
|
||||
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
|
||||
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
|
||||
// the ALREADY_EXISTS branch — validated on-glass). Same meaning: an instance is live.
|
||||
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!("{IN_USE}"),
|
||||
Err(e) => {
|
||||
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
|
||||
}
|
||||
};
|
||||
let already = GetLastError() == ERROR_ALREADY_EXISTS;
|
||||
let owned = OwnedHandle::from_raw_handle(h.0 as _);
|
||||
if already {
|
||||
anyhow::bail!("{IN_USE}");
|
||||
}
|
||||
Ok(owned)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//! Runtime display-management knobs read from the console policy (with legacy env-var fallbacks),
|
||||
//! carved out of the manager (plan §W3): the linger window, the keep-alive-forever pin, and the
|
||||
//! per-monitor topology action. Pure readers of [`crate::vdisplay::policy`] + env — no manager state.
|
||||
|
||||
/// Linger window before a session-less monitor is torn down. The console display-management policy
|
||||
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
|
||||
/// else the 10 s default.
|
||||
pub(super) fn linger_ms() -> u64 {
|
||||
use crate::vdisplay::policy::{prefs, Linger};
|
||||
if let Some(eff) = prefs().configured_effective() {
|
||||
return match eff.keep_alive.linger() {
|
||||
Linger::Immediate => 0,
|
||||
Linger::For(d) => d.as_millis() as u64,
|
||||
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
|
||||
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
|
||||
// check) — fall back to the default rather than a huge linger.
|
||||
Linger::Forever => 10_000,
|
||||
};
|
||||
}
|
||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000)
|
||||
}
|
||||
|
||||
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
|
||||
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
|
||||
/// lingering. Unconfigured hosts are never forever (default is a short linger).
|
||||
pub(super) fn keep_alive_forever() -> bool {
|
||||
use crate::vdisplay::policy::{prefs, Linger};
|
||||
prefs()
|
||||
.configured_effective()
|
||||
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
|
||||
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
|
||||
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
|
||||
/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the
|
||||
/// physical(s) so the IDD is the sole composited desktop.
|
||||
pub(super) fn topology_action() -> crate::vdisplay::policy::Topology {
|
||||
use crate::vdisplay::policy::Topology;
|
||||
if crate::vdisplay::policy::prefs()
|
||||
.configured_effective()
|
||||
.is_some()
|
||||
{
|
||||
return crate::vdisplay::effective_topology();
|
||||
}
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() {
|
||||
Topology::Extend
|
||||
} else {
|
||||
Topology::Exclusive
|
||||
}
|
||||
}
|
||||
@@ -1,728 +0,0 @@
|
||||
//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display
|
||||
//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor
|
||||
//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz`
|
||||
//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and
|
||||
//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
|
||||
//!
|
||||
//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with
|
||||
//! the wire contract OWNED by [`pf_driver_proto::control`] (versioned + `#[repr(C)] Pod` structs,
|
||||
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `design/windows-host-rewrite.md`.
|
||||
//!
|
||||
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
|
||||
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
|
||||
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
|
||||
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
|
||||
//! request/reply structs, the version handshake) differ, per `pf_driver_proto`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
|
||||
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, HDEVINFO, SPINT_ACTIVE,
|
||||
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
use windows::Win32::System::IO::DeviceIoControl;
|
||||
|
||||
use pf_driver_proto::control;
|
||||
|
||||
use super::manager::{AddedMonitor, MonitorKey, VdisplayDriver};
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
|
||||
// pf-vdisplay device-interface GUID (pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately
|
||||
// NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids
|
||||
// any accidental coexistence with a real SudoVDA install.
|
||||
const PF_VDISPLAY_INTERFACE: GUID =
|
||||
GUID::from_u128(pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128);
|
||||
|
||||
/// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike
|
||||
/// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level
|
||||
/// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a
|
||||
/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime.
|
||||
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
|
||||
fn next_session_id() -> u64 {
|
||||
NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the
|
||||
/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`.
|
||||
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
|
||||
let mut returned = 0u32;
|
||||
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
|
||||
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
|
||||
DeviceIoControl(
|
||||
h,
|
||||
code,
|
||||
inp,
|
||||
input.len() as u32,
|
||||
outp,
|
||||
output.len() as u32,
|
||||
Some(&mut returned),
|
||||
None,
|
||||
)
|
||||
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
|
||||
Ok(returned)
|
||||
}
|
||||
|
||||
/// Reap the ghost (NOT-present) "punktfunk" virtual-monitor device nodes that `IddCxMonitorDeparture`
|
||||
/// leaves behind. Each departed monitor leaves a not-present "Generic Monitor (punktfunk)" PDO that keeps
|
||||
/// pinning an OS VidPN target against the IddCx adapter's fixed monitor-slot budget; once ~16 accumulate,
|
||||
/// `IOCTL_ADD` wedges at 0x80070490 (`ERROR_NOT_FOUND`) and every session black-screens until a manual
|
||||
/// reset/reboot. Removing the not-present PDOs frees the slots — the in-process equivalent of
|
||||
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
|
||||
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
|
||||
/// failure is logged and swallowed. Returns the number removed.
|
||||
fn reap_ghost_monitors() -> u32 {
|
||||
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
|
||||
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
|
||||
// non-English box (unlike a .ps1 *file* read in the machine codepage).
|
||||
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$g = Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }; \
|
||||
$n = 0; foreach ($d in $g) { pnputil /remove-device $d.InstanceId *> $null; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||
Write-Output $n";
|
||||
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
|
||||
// System32 — with a bare-name fallback.
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
match std::process::Command::new(&ps)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
REAP_PS,
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let n = String::from_utf8_lossy(&o.stdout)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
if n > 0 {
|
||||
tracing::warn!(
|
||||
reaped = n,
|
||||
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
|
||||
);
|
||||
}
|
||||
n
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick the pf-vdisplay ADAPTER device (disable → enable) — the in-process equivalent of
|
||||
/// `reset-pf-vdisplay.ps1` step 3. A crashed/killed WUDFHost can leave the devnode "started" yet
|
||||
/// HOSTLESS (PnP Status OK, no WUDFHost process, zero device-interface instances) — a zombie no
|
||||
/// session can open until the stack reloads; on-glass, only a device cycle recovered it. Called by
|
||||
/// [`VdisplayDriver::open`] when `open_device` finds no openable interface; the caller retries the
|
||||
/// open afterwards. Best-effort + bounded (~7 s inside the script). Returns whether a punktfunk
|
||||
/// adapter devnode was found (and therefore cycled) — `false` means the driver genuinely is not
|
||||
/// installed and a retry is pointless.
|
||||
fn restart_vdisplay_device() -> bool {
|
||||
// Mirrors reset-pf-vdisplay.ps1's Get-PfAdapter selector ('punktfunk Virtual Display' is the INF
|
||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above.
|
||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
||||
if ($ad) { \
|
||||
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 3; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status; \
|
||||
if ($st -ne 'OK') { Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false; Start-Sleep -Seconds 2; \
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId).Status }; \
|
||||
Write-Output $st \
|
||||
} else { Write-Output 'ABSENT' }";
|
||||
let ps = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
match std::process::Command::new(&ps)
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
CYCLE_PS,
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if status == "ABSENT" {
|
||||
tracing::warn!("pf-vdisplay: no adapter devnode to cycle — driver not installed");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%status,
|
||||
"pf-vdisplay: cycled the adapter device (hostless-zombie recovery)"
|
||||
);
|
||||
}
|
||||
status != "ABSENT"
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "pf-vdisplay: adapter cycle could not spawn powershell");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `e`'s chain carries the IddCx monitor-slot-exhaustion wedge HRESULT (0x80070490,
|
||||
/// `ERROR_NOT_FOUND`) — the `IOCTL_ADD` failure that ghost-PDO accumulation produces. The hex code is
|
||||
/// locale-invariant (the OS message text is not), so we match on it.
|
||||
fn is_slot_exhaustion_wedge(e: &anyhow::Error) -> bool {
|
||||
format!("{e:#}").contains("0x80070490")
|
||||
}
|
||||
|
||||
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
|
||||
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
|
||||
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
|
||||
/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm). The driver
|
||||
/// implements it (`control.rs` → `adapter::set_render_adapter`); callers still tolerate an `Err`
|
||||
/// (warn + continue) since the driver reports its real render LUID in the shared header either way.
|
||||
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
|
||||
let req = control::SetRenderAdapterRequest {
|
||||
luid_low: luid.LowPart,
|
||||
luid_high: luid.HighPart,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
ioctl(
|
||||
h,
|
||||
control::IOCTL_SET_RENDER_ADAPTER,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
)
|
||||
.map(|_| ())
|
||||
.context("pf-vdisplay SET_RENDER_ADAPTER")
|
||||
}
|
||||
|
||||
/// Deliver a monitor's sealed frame channel to the driver: the handle values `req` carries were just
|
||||
/// duplicated into the driver's WUDFHost by the IDD-push capturer's broker (`idd_push::ChannelBroker`),
|
||||
/// and on IOCTL success the DRIVER owns them. No output buffer. The caller reaps the remote duplicates
|
||||
/// on failure (the broker's `DUPLICATE_CLOSE_SOURCE` sweep) so no path leaks WUDFHost handles.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be a live pf-vdisplay control handle (see [`super::manager::control_device_handle`]).
|
||||
pub(crate) unsafe fn send_frame_channel(
|
||||
dev: HANDLE,
|
||||
req: &control::SetFrameChannelRequest,
|
||||
) -> Result<()> {
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: per this fn's contract `dev` is the live control handle. `bytes_of(req)` borrows the
|
||||
// caller's request for the duration of this synchronous call as the input bytes; `none` is empty,
|
||||
// so there is no output buffer.
|
||||
unsafe {
|
||||
ioctl(
|
||||
dev,
|
||||
control::IOCTL_SET_FRAME_CHANNEL,
|
||||
bytemuck::bytes_of(req),
|
||||
&mut none,
|
||||
)
|
||||
}
|
||||
.map(|_| ())
|
||||
.context("pf-vdisplay SET_FRAME_CHANNEL")
|
||||
}
|
||||
|
||||
/// RAII over a SetupAPI device-info list: every exit path of [`open_device`] destroys it (the error
|
||||
/// paths used to leak one `HDEVINFO` per failed open — and a driverless / mid-upgrade box probes
|
||||
/// repeatedly).
|
||||
struct DevInfoList(HDEVINFO);
|
||||
|
||||
impl Drop for DevInfoList {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the live device-info list this wrapper solely owns; destroyed exactly
|
||||
// once here.
|
||||
unsafe {
|
||||
let _ = SetupDiDestroyDeviceInfoList(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn open_device() -> Result<HANDLE> {
|
||||
// SAFETY: plain SetupAPI enumeration call; the returned list is solely owned by the RAII wrapper.
|
||||
let hdev = DevInfoList(
|
||||
unsafe {
|
||||
SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
}
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?,
|
||||
);
|
||||
|
||||
// Enumerate EVERY interface instance, not just index 0: after a driver upgrade a present-but-
|
||||
// failed devnode (Code 10) can hold index 0 while the LIVE node's interface sits at a later
|
||||
// index — the old single-index read then failed every session with "driver not installed"
|
||||
// even though a working interface existed. `SPINT_ACTIVE` filters dead interfaces (an interface
|
||||
// is active only while its owning device is started); the first active + openable one wins.
|
||||
let mut inactive = 0u32;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for index in 0..64u32 {
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `hdev.0` is the live list; `idata` is a valid, size-stamped out-param.
|
||||
if unsafe {
|
||||
SetupDiEnumDeviceInterfaces(hdev.0, None, &PF_VDISPLAY_INTERFACE, index, &mut idata)
|
||||
}
|
||||
.is_err()
|
||||
{
|
||||
break; // ERROR_NO_MORE_ITEMS — no further candidates
|
||||
}
|
||||
if idata.Flags & SPINT_ACTIVE == 0 {
|
||||
inactive += 1;
|
||||
continue;
|
||||
}
|
||||
let mut required = 0u32;
|
||||
// SAFETY: sizing call — null buffer plus a valid `required` out-param; the expected
|
||||
// ERROR_INSUFFICIENT_BUFFER "failure" is ignored and only `required` is consumed.
|
||||
let _ = unsafe {
|
||||
SetupDiGetDeviceInterfaceDetailW(hdev.0, &idata, None, 0, Some(&mut required), None)
|
||||
};
|
||||
if (required as usize) < size_of::<u32>() {
|
||||
continue; // sizing failed — never stamp a cbSize through an under-sized buffer
|
||||
}
|
||||
let mut buf = vec![0u8; required as usize];
|
||||
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
|
||||
// SAFETY: `buf` is `required` bytes (>= 4, checked above), so stamping `cbSize` and letting
|
||||
// the API fill up to `required` bytes stays in bounds; `detail` aliases `buf` only within
|
||||
// this iteration, and the `DevicePath` pointer is read before `buf` is dropped.
|
||||
let opened = unsafe {
|
||||
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
|
||||
SetupDiGetDeviceInterfaceDetailW(hdev.0, &idata, Some(detail), required, None, None)
|
||||
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")
|
||||
.and_then(|()| {
|
||||
CreateFileW(
|
||||
PCWSTR((*detail).DevicePath.as_ptr()),
|
||||
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
None,
|
||||
)
|
||||
.context("CreateFileW(pf-vdisplay device)")
|
||||
})
|
||||
};
|
||||
match opened {
|
||||
Ok(h) => return Ok(h),
|
||||
// A raced-away or wedged device — remember the error, try the next interface.
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no ACTIVE pf-vdisplay device interface found ({inactive} inactive) — is the \
|
||||
pf-vdisplay driver installed and its device started?"
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
|
||||
/// (Goal-1 §2.5) — the wire contract is owned by `pf_driver_proto::control` (versioned, hard-checked).
|
||||
pub(crate) struct PfVdisplayDriver;
|
||||
|
||||
impl VdisplayDriver for PfVdisplayDriver {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)> {
|
||||
// SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW`
|
||||
// FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the
|
||||
// backend-init thread, with no precondition beyond a valid thread context.
|
||||
let device = match unsafe { open_device() } {
|
||||
Ok(d) => d,
|
||||
Err(first) => {
|
||||
// No openable interface. If a WUDFHost crash left the devnode a hostless zombie
|
||||
// (validated on-glass: PnP Status OK, zero interface instances), a device cycle
|
||||
// reloads the stack — kick it once and retry the open over a short arrival window.
|
||||
if !restart_vdisplay_device() {
|
||||
return Err(first); // no adapter devnode at all — genuinely not installed
|
||||
}
|
||||
let mut reopened = Err(first);
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
// SAFETY: as above — plain SetupAPI + CreateFileW FFI, no preconditions.
|
||||
match unsafe { open_device() } {
|
||||
Ok(d) => {
|
||||
reopened = Ok(d);
|
||||
break;
|
||||
}
|
||||
Err(e) => reopened = Err(e),
|
||||
}
|
||||
}
|
||||
reopened.context("pf-vdisplay interface still absent after an adapter cycle")?
|
||||
}
|
||||
};
|
||||
// Wrap IMMEDIATELY: every `?` below must close the device exactly once — the old
|
||||
// wrap-on-success-only shape leaked the raw handle whenever GET_INFO itself failed.
|
||||
// SAFETY: `device` is the valid handle `open_device` just returned; ownership transfers into
|
||||
// the `OwnedHandle` (single owner, `CloseHandle` on drop).
|
||||
let device = unsafe { OwnedHandle::from_raw_handle(device.0 as _) };
|
||||
let raw = HANDLE(device.as_raw_handle());
|
||||
// HARD protocol-version check (unlike SudoVDA's best-effort log): a mismatched host/driver pair
|
||||
// fails loudly here rather than corrupting the IOCTL stream.
|
||||
let mut info_buf = [0u8; size_of::<control::InfoReply>()];
|
||||
// SAFETY: `ioctl` requires `h` to be a valid device handle and its slices to be valid for the
|
||||
// call. `raw` borrows the live `OwnedHandle` above for this synchronous call. `IOCTL_GET_INFO`
|
||||
// takes no input (`&[]`) and writes into `info_buf`, a stack `[u8; size_of::<InfoReply>()]`
|
||||
// whose length is passed as the output size — so `DeviceIoControl` can't write OOB — and which
|
||||
// outlives this synchronous call.
|
||||
unsafe { ioctl(raw, control::IOCTL_GET_INFO, &[], &mut info_buf) }
|
||||
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
|
||||
let info: control::InfoReply =
|
||||
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
||||
if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION {
|
||||
anyhow::bail!(
|
||||
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
|
||||
host + driver",
|
||||
pf_driver_proto::PROTOCOL_VERSION,
|
||||
info.protocol_version
|
||||
);
|
||||
}
|
||||
let watchdog_s = info.watchdog_timeout_s.max(1);
|
||||
tracing::info!(
|
||||
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
||||
info.protocol_version,
|
||||
watchdog_s
|
||||
);
|
||||
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns
|
||||
// SUCCESS). FIRST open of the process only: a REOPEN (the manager retired a dead handle after
|
||||
// a driver upgrade / WUDFHost restart) can race sessions that still believe they are live, and
|
||||
// an unconditional CLEAR_ALL there would raze them.
|
||||
if !reap_orphans {
|
||||
reap_ghost_monitors();
|
||||
return Ok((device, watchdog_s));
|
||||
}
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: `raw` borrows the live `OwnedHandle` above. `IOCTL_CLEAR_ALL` has no input and no
|
||||
// output: `&[]` and the empty `none` slice pass zero-length buffers, so nothing is read or
|
||||
// written through them.
|
||||
if unsafe { ioctl(raw, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
|
||||
tracing::info!("cleared orphaned virtual monitors on host startup");
|
||||
} else {
|
||||
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
|
||||
}
|
||||
// CLEAR_ALL only departs the driver's own (in-process) monitor list; it can NOT remove the
|
||||
// OS-side not-present "Generic Monitor (punktfunk)" PDOs that a previous host-run's monitor
|
||||
// departures left behind. Reap those here so a fresh host start begins with a clean IddCx
|
||||
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
|
||||
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
|
||||
reap_ghost_monitors();
|
||||
Ok((device, watchdog_s))
|
||||
}
|
||||
|
||||
unsafe fn add_monitor(
|
||||
&self,
|
||||
dev: HANDLE,
|
||||
mode: Mode,
|
||||
render_luid: Option<LUID>,
|
||||
preferred_monitor_id: u32,
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
) -> Result<AddedMonitor> {
|
||||
let session_id = next_session_id();
|
||||
// The client display's volume rides into the monitor's EDID CTA HDR block; all-zero =
|
||||
// unknown → the driver keeps its built-in defaults (also what an un-upgraded driver, which
|
||||
// reads only the legacy 24-byte prefix, does).
|
||||
let (max_luminance_nits, max_frame_avg_nits, min_luminance_millinits) = client_hdr
|
||||
.map(|m| pf_frame::hdr::vdisplay_luminance_fields(&m))
|
||||
.unwrap_or((0, 0, 0));
|
||||
if max_luminance_nits > 0 {
|
||||
tracing::info!(
|
||||
max_luminance_nits,
|
||||
max_frame_avg_nits,
|
||||
min_luminance_millinits,
|
||||
"pf-vdisplay ADD: advertising the client display's HDR volume in the monitor EDID"
|
||||
);
|
||||
}
|
||||
let add = control::AddRequest {
|
||||
session_id,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_hz: mode.refresh_hz,
|
||||
preferred_monitor_id,
|
||||
max_luminance_nits,
|
||||
max_frame_avg_nits,
|
||||
min_luminance_millinits,
|
||||
_reserved: 0,
|
||||
};
|
||||
// SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports
|
||||
// its real render LUID in the shared header, so the host binds correctly even if this is ignored.
|
||||
if let Some(luid) = render_luid {
|
||||
// SAFETY: `add_monitor`'s `# Safety` contract guarantees `dev` is the live control handle,
|
||||
// which is `set_render_adapter`'s precondition; we forward it unchanged. `luid` is a plain
|
||||
// `Copy` `LUID` passed by value — no borrow crosses the call.
|
||||
match unsafe { set_render_adapter(dev, luid) } {
|
||||
Ok(()) => tracing::info!(
|
||||
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
"pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"pf-vdisplay SET_RENDER_ADAPTER failed (continuing on the natural adapter): {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
let mut out = [0u8; size_of::<control::AddReply>()];
|
||||
// SAFETY: per `add_monitor`'s contract `dev` is the live control handle. `bytemuck::bytes_of(&add)`
|
||||
// borrows the local `AddRequest` (alive across this synchronous call) as the input bytes, and
|
||||
// `out` is a stack `[u8; size_of::<AddReply>()]` whose length bounds the kernel's write — both
|
||||
// buffers outlive the call.
|
||||
let add_res = unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) };
|
||||
let add_res = match add_res {
|
||||
Err(e) if is_slot_exhaustion_wedge(&e) => {
|
||||
// The IddCx monitor-slot pool is exhausted by accumulated ghost (departed-but-not-present)
|
||||
// virtual-monitor PDOs → ADD failed 0x80070490. Reap the ghosts in-process and retry ONCE
|
||||
// so the wedge SELF-HEALS instead of hard-failing every session until a manual reset/reboot
|
||||
// (the long-standing failure mode). pnputil removal is synchronous; a brief settle lets the
|
||||
// OS recompute the adapter's monitor budget before the retry.
|
||||
let reaped = reap_ghost_monitors();
|
||||
tracing::warn!(
|
||||
reaped,
|
||||
"pf-vdisplay ADD wedged (0x80070490 ERROR_NOT_FOUND) — reaped ghost monitor nodes, retrying ADD"
|
||||
);
|
||||
// pnputil removal is durable (the ghosts are gone permanently), but the OS reclaims the
|
||||
// IddCx VidPN-target slots via ASYNC PnP teardown that can lag the synchronous pnputil
|
||||
// return. Retry the ADD a few times (300 ms apart, NO re-reap — the ghosts are already
|
||||
// removed) to ride out that variable reclaim latency rather than guess one magic settle.
|
||||
// ~1.5 s worst case, only on the rare wedge path.
|
||||
let mut res = Err(anyhow::anyhow!("pf-vdisplay ADD retry loop did not run"));
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
// SAFETY: identical to the first IOCTL_ADD above — `dev` is the live control handle
|
||||
// (`add_monitor`'s contract), and `bytemuck::bytes_of(&add)` + `&mut out` borrow locals
|
||||
// that outlive this synchronous call.
|
||||
res = unsafe {
|
||||
ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out)
|
||||
};
|
||||
if res.is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
add_res.with_context(|| {
|
||||
format!(
|
||||
"pf-vdisplay ADD {}x{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
)
|
||||
})?;
|
||||
// `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed 4-byte
|
||||
// alignment, and `from_bytes` PANICS on a mismatch. This copies into an aligned `AddReply`.
|
||||
let reply: control::AddReply =
|
||||
bytemuck::pod_read_unaligned(&out[..size_of::<control::AddReply>()]);
|
||||
let luid = LUID {
|
||||
LowPart: reply.adapter_luid_low,
|
||||
HighPart: reply.adapter_luid_high,
|
||||
};
|
||||
tracing::info!(
|
||||
target_id = reply.target_id,
|
||||
adapter_luid = %format_args!("{:#x}", luid.LowPart),
|
||||
wudf_pid = reply.wudf_pid,
|
||||
"pf-vdisplay monitor created {}x{}@{}",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz
|
||||
);
|
||||
// Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id?
|
||||
// A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes
|
||||
// the id it actually used. A mismatch means this session fell back to an auto id, so Windows won't
|
||||
// reapply this client's saved per-monitor config (scaling) until it gets its stable id back.
|
||||
if preferred_monitor_id != 0 {
|
||||
if reply.resolved_monitor_id == preferred_monitor_id {
|
||||
tracing::info!(
|
||||
monitor_id = preferred_monitor_id,
|
||||
"pf-vdisplay: per-client monitor id honored (stable identity → saved config persists)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
preferred = preferred_monitor_id,
|
||||
resolved = reply.resolved_monitor_id,
|
||||
"pf-vdisplay: preferred monitor id NOT honored (live-id collision, or a pre-Phase-2 \
|
||||
driver) — per-client config persistence degraded to auto identity this session"
|
||||
);
|
||||
}
|
||||
}
|
||||
// NOTE: `reply.adapter_luid` is the IddCx DISPLAY adapter
|
||||
// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid`), NOT the render GPU, so it can NOT validate
|
||||
// SET_RENDER_ADAPTER — a comparison against the pin here fired "DIFFERS from pinned" on
|
||||
// every ADD (verified on-glass: reply 0x22c05 vs pin 0x15b05 on a single-4090 box). The
|
||||
// driver reports its ACTUAL render adapter in the shared frame header; the IDD-push
|
||||
// capturer checks it there and rebinds on a mismatch.
|
||||
Ok(AddedMonitor {
|
||||
key: MonitorKey::Session(session_id),
|
||||
target_id: reply.target_id,
|
||||
luid,
|
||||
wudf_pid: reply.wudf_pid,
|
||||
resolved_monitor_id: reply.resolved_monitor_id,
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
|
||||
let MonitorKey::Session(session_id) = key else {
|
||||
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
||||
};
|
||||
let req = control::RemoveRequest {
|
||||
session_id: *session_id,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: per `remove_monitor`'s contract `dev` is the live control handle. `bytes_of(&req)`
|
||||
// borrows the local `RemoveRequest` for the duration of this synchronous call as the input
|
||||
// bytes; `none` is empty, so there is no output buffer.
|
||||
unsafe {
|
||||
ioctl(
|
||||
dev,
|
||||
control::IOCTL_REMOVE,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
)
|
||||
}
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
unsafe fn ping(&self, dev: HANDLE) -> Result<()> {
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: per `ping`'s contract `dev` is the live control handle. `IOCTL_PING` has no input
|
||||
// (`&[]`) and no output (`none` is empty), so no memory is read or written through the buffers.
|
||||
unsafe { ioctl(dev, control::IOCTL_PING, &[], &mut none) }.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows pf-vdisplay virtual-display backend. Near-stateless — the lifecycle lives in the shared
|
||||
/// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager); it only carries the connecting
|
||||
/// client's fingerprint so the manager can assign a STABLE per-client monitor id (config persistence).
|
||||
pub struct PfVdisplayDisplay {
|
||||
/// The connecting client's cert fingerprint (`None` = anonymous/GameStream → the manager's auto id).
|
||||
/// Set by [`set_client_identity`](VirtualDisplay::set_client_identity) before `create`.
|
||||
client_fp: Option<[u8; 32]>,
|
||||
/// The client display's HDR colour volume (`None` = unknown/SDR → the driver's built-in EDID
|
||||
/// defaults). Set by [`set_client_hdr`](VirtualDisplay::set_client_hdr) before `create`; a
|
||||
/// freshly created monitor's EDID advertises this volume so host apps tone-map to the client's
|
||||
/// real panel.
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
/// The session's deliberate-quit flag (`None` = no signal → the linger policy applies). Set by
|
||||
/// [`set_quit_flag`](VirtualDisplay::set_quit_flag) before `create`; rides into every lease this
|
||||
/// backend mints so a user "stop" tears the monitor down immediately instead of lingering.
|
||||
quit: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
impl PfVdisplayDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
|
||||
Ok(Self {
|
||||
client_fp: None,
|
||||
client_hdr: None,
|
||||
quit: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for PfVdisplayDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
|
||||
self.client_fp = fingerprint;
|
||||
}
|
||||
|
||||
fn set_client_hdr(&mut self, hdr: Option<punktfunk_core::quic::HdrMeta>) {
|
||||
self.client_hdr = hdr;
|
||||
}
|
||||
|
||||
fn set_quit_flag(&mut self, quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
||||
self.quit = Some(quit);
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
super::manager::vdm().acquire(mode, self.client_fp, self.client_hdr, self.quit.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: can we open the pf-vdisplay control device?
|
||||
pub fn probe() -> Result<()> {
|
||||
// SAFETY: `open_device` is `unsafe` only for its SetupAPI + `CreateFileW` FFI; no arguments, returns
|
||||
// an owned raw `HANDLE` (or `Err`).
|
||||
let h = unsafe { open_device()? };
|
||||
// SAFETY: `h` is the handle just opened by `open_device` in this function, owned here and not yet
|
||||
// handed anywhere else, so this closes it exactly once — no double-close, no use-after-close.
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the pf-vdisplay driver present (device interface enumerable)?
|
||||
pub fn is_available() -> bool {
|
||||
// SAFETY: `open_device` returns an owned raw `HANDLE`; on `Ok(h)` the handle is moved into the
|
||||
// closure (sole owner) and closed exactly once via `CloseHandle`, on `Err` there is nothing to
|
||||
// close — so no double-close and no leak of an opened handle. The `unsafe` covers both FFI calls.
|
||||
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
|
||||
}
|
||||
|
||||
/// [`is_available`], with self-heal: an interface-less driver whose adapter devnode EXISTS is the
|
||||
/// hostless-zombie state a WUDFHost crash leaves behind (validated on-glass — PnP reports Status OK
|
||||
/// with no WUDFHost process and zero interface instances, and every session fails at this gate until
|
||||
/// the device reloads). Cycle the adapter once and re-probe over a short arrival window. A genuinely
|
||||
/// uninstalled driver (no adapter devnode) fails fast without the wait.
|
||||
pub fn ensure_available() -> bool {
|
||||
if is_available() {
|
||||
return true;
|
||||
}
|
||||
if !restart_vdisplay_device() {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..8 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if is_available() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay
|
||||
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
fn live_create_drop() {
|
||||
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
|
||||
return;
|
||||
}
|
||||
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
|
||||
let vout = vd
|
||||
.create(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})
|
||||
.expect("create virtual display");
|
||||
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
drop(vout); // triggers REMOVE + stops the pinger
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
//! DDC/CI monitor panel power control — the EXPERIMENTAL `ddc_power_off` display-policy axis.
|
||||
//!
|
||||
//! DDC/CI is the VESA command channel to the monitor itself: an I²C bus inside the video cable
|
||||
//! (dedicated pins on VGA/DVI/HDMI, tunneled over the AUX channel on DisplayPort) whose MCCS
|
||||
//! "VCP codes" expose the monitor's OSD knobs to software. VCP 0xD6 is the power mode; we command
|
||||
//! `0x04` (DPMS off — panel + backlight dark, firmware still listening) and never `0x05`
|
||||
//! (power-button off — many monitors kill their DDC controller in that state and need a physical
|
||||
//! button press to come back).
|
||||
//!
|
||||
//! Why: the "periodic double-jolt while the virtual display is the SOLE active display" stutter
|
||||
//! class (Apollo #179/#358/#368/#563/#776 and our own field report). When an `Exclusive` isolate
|
||||
//! deactivates the physical monitor, its link drops and the monitor falls into its no-signal flow:
|
||||
//! standby with periodic auto-input-scan / link probing that the GPU driver services with
|
||||
//! display-subsystem stalls at a seconds-scale cadence. A panel commanded off over DDC/CI believes
|
||||
//! it has an owner and (on cooperating firmware) stops probing. This is deliberately shipped as an
|
||||
//! experiment: whether it helps discriminates *who initiates* the churn — monitor firmware (DDC-off
|
||||
//! fixes it) vs. the driver servicing a dark head regardless (only a driven link fixes it, i.e.
|
||||
//! topology `primary`/`extend`).
|
||||
//!
|
||||
//! Everything here is best-effort and warn-and-continue: monitors without DDC/CI support (or with
|
||||
//! it disabled in the OSD), docks/KVMs that don't pass the channel through, and laptop-internal
|
||||
//! panels (ACPI backlight, no DDC) all simply probe as unsupported and are skipped. Each DDC
|
||||
//! transaction can block for tens of ms — callers run at session acquire/teardown, never on the
|
||||
//! frame path.
|
||||
|
||||
use windows::Win32::Devices::Display::{
|
||||
DestroyPhysicalMonitors, GetNumberOfPhysicalMonitorsFromHMONITOR,
|
||||
GetPhysicalMonitorsFromHMONITOR, GetVCPFeatureAndVCPFeatureReply, SetVCPFeature,
|
||||
PHYSICAL_MONITOR,
|
||||
};
|
||||
use windows::Win32::Foundation::LPARAM;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
EnumDisplayMonitors, GetMonitorInfoW, HDC, HMONITOR, MONITORINFOEXW,
|
||||
};
|
||||
|
||||
/// MCCS VCP code 0xD6 — display power mode.
|
||||
const VCP_POWER_MODE: u8 = 0xD6;
|
||||
/// VCP 0xD6 value: on.
|
||||
const POWER_ON: u32 = 0x01;
|
||||
/// VCP 0xD6 value: DPMS off (dark panel, DDC controller stays responsive). Deliberately NOT 0x05.
|
||||
const POWER_OFF: u32 = 0x04;
|
||||
|
||||
/// One active display: its HMONITOR and GDI device name (`\\.\DISPLAYn`).
|
||||
struct ActiveMonitor {
|
||||
hmon: HMONITOR,
|
||||
device: String,
|
||||
}
|
||||
|
||||
/// Enumerate the active displays (HMONITOR + GDI name). HMONITORs are only valid while a display
|
||||
/// is part of the desktop — which is exactly why the off-command must run BEFORE a CCD isolate
|
||||
/// and the on-command AFTER the restore.
|
||||
fn active_monitors() -> Vec<ActiveMonitor> {
|
||||
unsafe extern "system" fn collect(
|
||||
hmon: HMONITOR,
|
||||
_hdc: HDC,
|
||||
_rect: *mut windows::Win32::Foundation::RECT,
|
||||
data: LPARAM,
|
||||
) -> windows::core::BOOL {
|
||||
// SAFETY: `data` is the `&mut Vec<ActiveMonitor>` passed by `active_monitors` below,
|
||||
// valid for the duration of the synchronous EnumDisplayMonitors call that invokes us.
|
||||
let out = unsafe { &mut *(data.0 as *mut Vec<ActiveMonitor>) };
|
||||
let mut info = MONITORINFOEXW::default();
|
||||
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
|
||||
// SAFETY: `hmon` is the live monitor handle the enumeration just handed us; `info` is a
|
||||
// properly-sized MONITORINFOEXW local whose cbSize is set, which GetMonitorInfoW requires
|
||||
// to safely write the extended (szDevice) variant.
|
||||
if unsafe { GetMonitorInfoW(hmon, &mut info.monitorInfo) }.as_bool() {
|
||||
let len = info
|
||||
.szDevice
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(info.szDevice.len());
|
||||
out.push(ActiveMonitor {
|
||||
hmon,
|
||||
device: String::from_utf16_lossy(&info.szDevice[..len]),
|
||||
});
|
||||
}
|
||||
true.into() // keep enumerating
|
||||
}
|
||||
|
||||
let mut out: Vec<ActiveMonitor> = Vec::new();
|
||||
// SAFETY: `collect` matches MONITORENUMPROC; `&mut out` outlives the synchronous enumeration
|
||||
// and is only dereferenced inside the callback (single-threaded — user32 invokes it inline).
|
||||
let _ = unsafe {
|
||||
EnumDisplayMonitors(
|
||||
None,
|
||||
None,
|
||||
Some(collect),
|
||||
LPARAM(&mut out as *mut Vec<ActiveMonitor> as isize),
|
||||
)
|
||||
};
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply `value` to VCP 0xD6 on every physical monitor behind `hmon` that answers a 0xD6 probe.
|
||||
/// Returns how many panels acknowledged the set. `device` is for the log lines only.
|
||||
fn set_power(hmon: HMONITOR, device: &str, value: u32) -> u32 {
|
||||
let mut n = 0u32;
|
||||
// SAFETY: `hmon` is a live monitor handle from the enumeration; `n` is a valid out-param.
|
||||
if unsafe { GetNumberOfPhysicalMonitorsFromHMONITOR(hmon, &mut n) }.is_err() || n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut phys = vec![PHYSICAL_MONITOR::default(); n as usize];
|
||||
// SAFETY: `phys` is sized to exactly the count the API just reported for this handle.
|
||||
if unsafe { GetPhysicalMonitorsFromHMONITOR(hmon, &mut phys) }.is_err() {
|
||||
return 0;
|
||||
}
|
||||
let mut acked = 0u32;
|
||||
for p in &phys {
|
||||
// PHYSICAL_MONITOR is `packed(1)` (dxva2 header pragma) — copy the fields OUT by value
|
||||
// before touching them; a reference into a packed field is rejected (E0793, UB).
|
||||
let handle = p.hPhysicalMonitor;
|
||||
let desc_raw = p.szPhysicalMonitorDescription;
|
||||
let len = desc_raw
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(desc_raw.len());
|
||||
let desc = String::from_utf16_lossy(&desc_raw[..len]);
|
||||
// Probe first: a monitor without DDC/CI (or with it disabled in the OSD, or behind a
|
||||
// dock/KVM that drops the channel) fails here and is skipped — never blind-write to a
|
||||
// bus we can't read.
|
||||
let (mut current, mut max) = (0u32, 0u32);
|
||||
// SAFETY: `handle` is the live physical-monitor handle (valid until
|
||||
// DestroyPhysicalMonitors below); the value pointers are valid locals ('None' for the
|
||||
// code-type out-param we don't need).
|
||||
let probe = unsafe {
|
||||
GetVCPFeatureAndVCPFeatureReply(
|
||||
handle,
|
||||
VCP_POWER_MODE,
|
||||
None,
|
||||
&mut current,
|
||||
Some(&mut max),
|
||||
)
|
||||
};
|
||||
if probe == 0 {
|
||||
tracing::debug!(
|
||||
device,
|
||||
monitor = desc,
|
||||
"DDC/CI: no reply to the power-mode (0xD6) probe — skipping (no DDC/CI, \
|
||||
disabled in the OSD, or not passed through)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// SAFETY: as the probe above — same live physical-monitor handle, plain value args.
|
||||
let set = unsafe { SetVCPFeature(handle, VCP_POWER_MODE, value) };
|
||||
if set == 0 {
|
||||
tracing::warn!(
|
||||
device,
|
||||
monitor = desc,
|
||||
value,
|
||||
"DDC/CI: power-mode set failed after a successful probe"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
device,
|
||||
monitor = desc,
|
||||
from = current,
|
||||
to = value,
|
||||
"DDC/CI: panel power mode commanded"
|
||||
);
|
||||
acked += 1;
|
||||
}
|
||||
}
|
||||
// SAFETY: `phys` holds exactly the handles GetPhysicalMonitorsFromHMONITOR opened for us;
|
||||
// each is destroyed once, here.
|
||||
if let Err(e) = unsafe { DestroyPhysicalMonitors(&phys) } {
|
||||
tracing::debug!(device, "DDC/CI: DestroyPhysicalMonitors failed: {e}");
|
||||
}
|
||||
acked
|
||||
}
|
||||
|
||||
/// Command every physical panel EXCEPT `exclude_gdi` (the virtual display) off via DDC/CI
|
||||
/// (VCP 0xD6 → DPMS off). Call while the physical displays are still ACTIVE — i.e. immediately
|
||||
/// before the `Exclusive` CCD isolate. Returns how many panels acknowledged.
|
||||
pub fn panel_off_except(exclude_gdi: &str) -> u32 {
|
||||
let mut acked = 0;
|
||||
for m in active_monitors() {
|
||||
if m.device.eq_ignore_ascii_case(exclude_gdi) {
|
||||
continue;
|
||||
}
|
||||
acked += set_power(m.hmon, &m.device, POWER_OFF);
|
||||
}
|
||||
if acked == 0 {
|
||||
tracing::debug!(
|
||||
"DDC/CI: no physical panel accepted the DPMS-off command \
|
||||
(no DDC/CI-capable panel besides the virtual display)"
|
||||
);
|
||||
}
|
||||
acked
|
||||
}
|
||||
|
||||
/// Best-effort wake: command ON to every physical panel that answers. Call AFTER the CCD restore
|
||||
/// has re-activated the physical paths — the returning signal alone wakes DPMS-off panels on most
|
||||
/// firmware; this is the belt-and-braces for the rest.
|
||||
pub fn panel_on_all() -> u32 {
|
||||
let mut acked = 0;
|
||||
for m in active_monitors() {
|
||||
acked += set_power(m.hmon, &m.device, POWER_ON);
|
||||
}
|
||||
acked
|
||||
}
|
||||
Reference in New Issue
Block a user