diff --git a/Cargo.lock b/Cargo.lock index 0b128405..e0938a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2930,6 +2930,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pf-vdisplay" +version = "0.12.0" +dependencies = [ + "anyhow", + "ashpd", + "bytemuck", + "futures-util", + "hex", + "libc", + "pf-driver-proto", + "pf-encode", + "pf-frame", + "pf-gpu", + "pf-host-config", + "pf-paths", + "pf-win-display", + "punktfunk-core", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", + "utoipa", + "wayland-backend", + "wayland-client", + "wayland-scanner", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "pf-win-display" version = "0.12.0" @@ -3259,6 +3289,7 @@ dependencies = [ "pf-host-config", "pf-inject", "pf-paths", + "pf-vdisplay", "pf-win-display", "pf-zerocopy", "pipewire", diff --git a/Cargo.toml b/Cargo.toml index 9793eb2a..c8e6363f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/pf-encode", "crates/pf-capture", "crates/pf-inject", + "crates/pf-vdisplay", "crates/pyrowave-sys", "clients/probe", "clients/linux", diff --git a/crates/pf-vdisplay/Cargo.toml b/crates/pf-vdisplay/Cargo.toml new file mode 100644 index 00000000..8f186085 --- /dev/null +++ b/crates/pf-vdisplay/Cargo.toml @@ -0,0 +1,61 @@ +# Virtual-display orchestration (plan §W6): the on-demand client-sized headless output — per-compositor +# Linux backends (KWin zkde-screencast, wlroots swaymsg, Mutter RemoteDesktop, Hyprland) and the +# Windows IddCx/pf-vdisplay driver backend — behind one VirtualDisplay trait, plus the mode-conflict +# admission registry and the DDC/CI panel control. Extracted into a subsystem crate; depends on the +# shared leaves (pf-frame's DXGI identity, pf-win-display's CCD helpers, pf-gpu, pf-paths) + pf-encode +# (the NVENC session-budget admission gate), never on capture/inject or the orchestrator (the display +# lifecycle events invert to a host-registered sink). +[package] +name = "pf-vdisplay" +version = "0.12.0" +edition = "2021" +rust-version.workspace = true +license = "MIT OR Apache-2.0" +description = "punktfunk host virtual-display orchestration: per-compositor Linux backends + the Windows IddCx driver backend behind one VirtualDisplay trait." +publish = false + +[dependencies] +punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +pf-frame = { path = "../pf-frame" } +pf-gpu = { path = "../pf-gpu" } +pf-host-config = { path = "../pf-host-config" } +pf-paths = { path = "../pf-paths" } +pf-win-display = { path = "../pf-win-display" } +# The Windows admission gate consults NVENC's session budget (can_open_another_session). +pf-encode = { path = "../pf-encode" } +anyhow = "1" +tracing = "0.1" +# The platform-neutral policy/identity/custom-preset state is serde-serialized (persisted + the mgmt +# API), and the policy/preset types derive utoipa `ToSchema` for the OpenAPI document. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +utoipa = { version = "5", features = ["axum_extras"] } +sha2 = "0.10" +hex = "0.4" + +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" +# The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio +# runtime; the gamescope restore worker + portal handshakes use tokio too. +ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "time"] } +futures-util = "0.3" +# KWin virtual-output creation via the privileged `zkde_screencast_unstable_v1` protocol (vendored in +# `protocols/`); the generated interface tables reference `wayland-backend`. +wayland-client = "0.31" +wayland-scanner = "0.31" +wayland-backend = "0.3" + +[target.'cfg(target_os = "windows")'.dependencies] +# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs). +pf-driver-proto = { path = "../pf-driver-proto" } +bytemuck = { version = "1.19", features = ["derive"] } +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Display", + "Win32_Graphics_Gdi", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Threading", +] } diff --git a/crates/punktfunk-host/protocols/zkde-screencast-unstable-v1.xml b/crates/pf-vdisplay/protocols/zkde-screencast-unstable-v1.xml similarity index 100% rename from crates/punktfunk-host/protocols/zkde-screencast-unstable-v1.xml rename to crates/pf-vdisplay/protocols/zkde-screencast-unstable-v1.xml diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/pf-vdisplay/src/lib.rs similarity index 90% rename from crates/punktfunk-host/src/vdisplay.rs rename to crates/pf-vdisplay/src/lib.rs index c1a66ce3..acfa3698 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -1,4 +1,4 @@ -//! Virtual display orchestration (plan §6) — the project's differentiator. +//! Virtual display orchestration (plan §6 / §W6) — 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 @@ -11,8 +11,10 @@ //! //! [`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`]. +//! consumes the node via the host `capture::capture_virtual_output`. +// Scaffold: some backend paths + Stage-3 identity are defined ahead of the target that uses them. +#![allow(dead_code)] // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] @@ -21,8 +23,36 @@ pub use punktfunk_core::Mode; #[cfg(target_os = "linux")] use std::os::fd::OwnedFd; +/// A display-lifecycle event the registry emits when it creates or releases a managed virtual +/// display. The host wires [`DISPLAY_EVENT_SINK`] to translate these into its SSE event bus +/// (`crate::events` in the orchestrator), so this crate emits lifecycle signals without owning the +/// bus type — the one reach into the orchestrator's event module, inverted to a leaf hook (plan §W6). +pub enum DisplayEvent { + /// A virtual display was created on `backend` at `width`×`height`@`refresh_hz`. + Created { + backend: String, + width: u32, + height: u32, + refresh_hz: u32, + }, + /// `count` managed displays were released. + Released { count: u32 }, +} + +/// The host-registered sink that forwards [`DisplayEvent`]s to the SSE bus. Set once at startup; a +/// display event before it is set is silently dropped (no subscriber yet). +pub static DISPLAY_EVENT_SINK: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Emit a [`DisplayEvent`] to the host sink, if registered. +pub(crate) fn emit_display_event(ev: DisplayEvent) { + if let Some(sink) = DISPLAY_EVENT_SINK.get() { + sink(ev); + } +} + /// The virtual-display backend contract — [`DisplayOwnership`], [`VirtualOutput`], and the -/// [`VirtualDisplay`] trait (plan §W3). Re-exported so `crate::vdisplay::VirtualDisplay` etc. stay +/// [`VirtualDisplay`] trait (plan §W3). Re-exported so `crate::VirtualDisplay` etc. stay /// stable for the ~30 external call sites. #[path = "vdisplay/backend.rs"] pub(crate) mod backend; @@ -104,7 +134,7 @@ impl Compositor { 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). + // (`pick_compositor` (host `native`) resolves the family). Compositor::Hyprland => P::Wlroots, } } @@ -244,11 +274,11 @@ pub fn open(compositor: Compositor) -> Result> { // `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(), + driver::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()?)) + Ok(Box::new(driver::PfVdisplayDisplay::new()?)) } #[cfg(not(any(target_os = "linux", target_os = "windows")))] { @@ -280,7 +310,7 @@ pub fn probe(compositor: Compositor) -> Result<()> { #[cfg(target_os = "windows")] { let _ = compositor; - pf_vdisplay::probe() + driver::probe() } #[cfg(not(any(target_os = "linux", target_os = "windows")))] { @@ -293,7 +323,7 @@ pub fn probe(compositor: Compositor) -> Result<()> { // 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; +pub 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. @@ -303,7 +333,7 @@ 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; +pub 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. @@ -356,7 +386,7 @@ pub fn effective_topology() -> policy::Topology { } // 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. +// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::*` module names flat. #[cfg(target_os = "linux")] #[path = "vdisplay/linux/gamescope.rs"] mod gamescope; @@ -371,7 +401,7 @@ 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; +pub mod admission; #[cfg(target_os = "linux")] #[path = "vdisplay/linux/hyprland.rs"] @@ -383,7 +413,13 @@ mod kwin; #[cfg(target_os = "windows")] #[path = "vdisplay/windows/manager.rs"] -pub(crate) mod manager; +pub mod manager; + +// DDC/CI panel power control (physical monitors), used only by the Windows manager to blank/wake the +// box's real panels around a virtual-display session — moved in with the subsystem (plan §W6). +#[cfg(target_os = "windows")] +#[path = "vdisplay/ddc.rs"] +mod ddc; #[cfg(target_os = "linux")] #[path = "vdisplay/linux/mutter.rs"] @@ -391,7 +427,7 @@ mod mutter; #[cfg(target_os = "windows")] #[path = "vdisplay/windows/pf_vdisplay.rs"] -pub(crate) mod pf_vdisplay; +pub mod driver; #[cfg(target_os = "linux")] #[path = "vdisplay/linux/wlroots.rs"] diff --git a/crates/punktfunk-host/src/vdisplay/admission.rs b/crates/pf-vdisplay/src/vdisplay/admission.rs similarity index 99% rename from crates/punktfunk-host/src/vdisplay/admission.rs rename to crates/pf-vdisplay/src/vdisplay/admission.rs index 21771728..66c6e389 100644 --- a/crates/punktfunk-host/src/vdisplay/admission.rs +++ b/crates/pf-vdisplay/src/vdisplay/admission.rs @@ -16,7 +16,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use crate::vdisplay::policy::{self, ModeConflict}; +use crate::policy::{self, ModeConflict}; /// A currently-live session, as admission sees it. #[derive(Clone)] @@ -135,7 +135,7 @@ pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { "host display budget exhausted: {slots} display(s) live/kept, max_displays = {max}" )); } - if !crate::encode::can_open_another_session() { + if !pf_encode::can_open_another_session() { return Admission::Reject( "host encoder budget exhausted: no NVENC session headroom for another display" .to_string(), diff --git a/crates/punktfunk-host/src/vdisplay/backend.rs b/crates/pf-vdisplay/src/vdisplay/backend.rs similarity index 96% rename from crates/punktfunk-host/src/vdisplay/backend.rs rename to crates/pf-vdisplay/src/vdisplay/backend.rs index c4f748e2..49ce7830 100644 --- a/crates/punktfunk-host/src/vdisplay/backend.rs +++ b/crates/pf-vdisplay/src/vdisplay/backend.rs @@ -46,25 +46,25 @@ pub struct VirtualOutput { /// 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. + /// what the host `capture::capture_virtual_output` needs to duplicate the right output. #[cfg(target_os = "windows")] - pub win_capture: Option, + pub win_capture: Option, /// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown. pub keepalive: Box, /// 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 + /// `Some(gen)` when [`registry::acquire`](crate::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 + /// can [`registry::mark_failed(gen)`](crate::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, /// 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 + /// mid-stream mode-switch rebuild can [`registry::retire`](crate::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). @@ -197,7 +197,7 @@ pub trait VirtualDisplay: Send { /// 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 + /// [`mark_failed`]: crate::registry::mark_failed fn kept_display_alive(&mut self, _node_id: u32) -> bool { true } diff --git a/crates/punktfunk-host/src/windows/ddc.rs b/crates/pf-vdisplay/src/vdisplay/ddc.rs similarity index 100% rename from crates/punktfunk-host/src/windows/ddc.rs rename to crates/pf-vdisplay/src/vdisplay/ddc.rs diff --git a/crates/punktfunk-host/src/vdisplay/identity.rs b/crates/pf-vdisplay/src/vdisplay/identity.rs similarity index 97% rename from crates/punktfunk-host/src/vdisplay/identity.rs rename to crates/pf-vdisplay/src/vdisplay/identity.rs index b8bc1711..4d55c191 100644 --- a/crates/punktfunk-host/src/vdisplay/identity.rs +++ b/crates/pf-vdisplay/src/vdisplay/identity.rs @@ -167,10 +167,10 @@ pub(crate) fn global() -> &'static Mutex { pub(crate) fn resolve_slot( fp: Option<[u8; 32]>, mode: (u32, u32), - default: crate::vdisplay::policy::Identity, + default: crate::policy::Identity, ) -> Option { - use crate::vdisplay::policy::Identity; - let id_policy = crate::vdisplay::policy::prefs() + use crate::policy::Identity; + let id_policy = crate::policy::prefs() .configured_effective() .map(|e| e.identity) .unwrap_or(default); @@ -201,9 +201,9 @@ const SCALE_FILE: &str = "display-scale.json"; pub(crate) fn scale_key( fp: Option<[u8; 32]>, mode: (u32, u32), - default: crate::vdisplay::policy::Identity, + default: crate::policy::Identity, ) -> String { - let id_policy = crate::vdisplay::policy::prefs() + let id_policy = crate::policy::prefs() .configured_effective() .map(|e| e.identity) .unwrap_or(default); @@ -212,11 +212,11 @@ pub(crate) fn scale_key( /// Pure core of [`scale_key`] (policy already resolved) — unit-testable without the global store. fn scale_key_for( - policy: crate::vdisplay::policy::Identity, + policy: crate::policy::Identity, fp: Option<[u8; 32]>, mode: (u32, u32), ) -> String { - use crate::vdisplay::policy::Identity; + use crate::policy::Identity; match (policy, fp) { (Identity::Shared, _) | (_, None) => "shared".to_string(), (Identity::PerClient, Some(fp)) => identity_key(fp, mode, false), @@ -343,7 +343,7 @@ mod tests { #[test] fn scale_key_follows_the_identity_policy() { - use crate::vdisplay::policy::Identity; + use crate::policy::Identity; // Shared / anonymous → the fixed shared slot. assert_eq!( scale_key_for(Identity::Shared, Some(fp(1)), (1920, 1080)), diff --git a/crates/punktfunk-host/src/vdisplay/layout.rs b/crates/pf-vdisplay/src/vdisplay/layout.rs similarity index 99% rename from crates/punktfunk-host/src/vdisplay/layout.rs rename to crates/pf-vdisplay/src/vdisplay/layout.rs index 55f8b742..e7d71eb1 100644 --- a/crates/punktfunk-host/src/vdisplay/layout.rs +++ b/crates/pf-vdisplay/src/vdisplay/layout.rs @@ -69,7 +69,7 @@ pub fn arrange(members: &[Member], layout: &Layout) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::vdisplay::policy::Position; + use crate::policy::Position; use std::collections::BTreeMap; fn m(slot: Option, width: i32) -> Member { diff --git a/crates/punktfunk-host/src/vdisplay/lifecycle.rs b/crates/pf-vdisplay/src/vdisplay/lifecycle.rs similarity index 100% rename from crates/punktfunk-host/src/vdisplay/lifecycle.rs rename to crates/pf-vdisplay/src/vdisplay/lifecycle.rs diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs similarity index 99% rename from crates/punktfunk-host/src/vdisplay/linux/gamescope.rs rename to crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index c5975f7f..9b2fba0e 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -195,9 +195,9 @@ impl VirtualDisplay for GamescopeDisplay { // Only a bare SPAWN is registry-poolable (its `create` reports `Owned`); managed // (`PUNKTFUNK_GAMESCOPE_SESSION`) and attach (`PUNKTFUNK_GAMESCOPE_NODE`) report // `SessionManaged`/`External`, so the registry must not reuse a kept spawn for them (same - // backend name). Mirrors [`crate::vdisplay::launch_is_nested`]; read under the env lock the + // backend name). Mirrors [`crate::launch_is_nested`]; read under the env lock the // sub-mode ladder writes these keys under. - crate::vdisplay::with_env_lock(|| { + crate::with_env_lock(|| { std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() && std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() }) @@ -395,7 +395,7 @@ fn steamos_session_present() -> bool { /// Does this box have the infrastructure the MANAGED gamescope mode drives — Bazzite's /// `gamescope-session-plus` or SteamOS's `gamescope-session`? The sub-mode ladder -/// ([`crate::vdisplay::apply_input_env`]) only defaults to managed when this is true; a plain +/// ([`crate::apply_input_env`]) only defaults to managed when this is true; a plain /// distro (neither present) falls through to the bare-spawn path instead of the old behaviour of /// defaulting to managed and then bailing on the missing session script. pub fn managed_session_available() -> bool { @@ -1027,7 +1027,7 @@ pub fn cancel_pending_restore() { /// manual return to gaming mode (the `gaming-rig` "the TV model" story, now truthful on gamescope); /// * unconfigured → the historical 5 s [`RESTORE_DEBOUNCE`] (bit-for-bit today's behavior). fn restore_delay() -> Option { - use crate::vdisplay::policy::{self, Linger}; + use crate::policy::{self, Linger}; match policy::prefs() .configured_effective() .map(|e| e.keep_alive.linger()) @@ -1328,19 +1328,19 @@ fn stop_session(unit_name: &str) { } /// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket), read by -/// the libei injector to drive input into the nested app. See [`crate::inject`]. +/// the libei injector to drive input into the nested app. See the `pf-inject` crate. /// /// Placed under `$XDG_RUNTIME_DIR` (a per-user, 0700 directory) — NOT a world-writable `/tmp` — /// so a second unprivileged local user can neither read the relayed socket path nor pre-plant the /// file to redirect the host's injector to a rogue EIS server (which would let them keylog or deny /// the remote session's keyboard/mouse input; security-review 2026-06-28 #6). Falls back to `/tmp` /// only if `XDG_RUNTIME_DIR` is unset (gamescope itself requires it, so this is rare); the reader -/// ([`crate::inject`]) additionally rejects a symlinked relay file as defense-in-depth. +/// (the `pf-inject` crate) additionally rejects a symlinked relay file as defense-in-depth. pub fn ei_socket_file() -> std::path::PathBuf { // The path itself is the shared `pf_paths::gamescope_ei_socket_file` contract (also read by the // libei injector). Compute it under the session env lock so a concurrent session handshake's // `apply_session_env` XDG_RUNTIME_DIR retarget can't race this producer-side read. - crate::vdisplay::with_env_lock(pf_paths::gamescope_ei_socket_file) + crate::with_env_lock(pf_paths::gamescope_ei_socket_file) } /// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch @@ -1398,7 +1398,7 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R // Read the env fallback under the shared env lock so it can't race a concurrent session's // `set_var` of the same key (security-review 2026-06-28 #7). .or_else(|| { - crate::vdisplay::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok()) + crate::with_env_lock(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok()) }) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "sleep infinity".to_string()); diff --git a/crates/punktfunk-host/src/vdisplay/linux/gamescope/discovery.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs similarity index 100% rename from crates/punktfunk-host/src/vdisplay/linux/gamescope/discovery.rs rename to crates/pf-vdisplay/src/vdisplay/linux/gamescope/discovery.rs diff --git a/crates/punktfunk-host/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs similarity index 100% rename from crates/punktfunk-host/src/vdisplay/linux/hyprland.rs rename to crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs similarity index 99% rename from crates/punktfunk-host/src/vdisplay/linux/kwin.rs rename to crates/pf-vdisplay/src/vdisplay/linux/kwin.rs index 5118c5ed..604e8cae 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs @@ -147,10 +147,10 @@ impl VirtualDisplay for KwinDisplay { // 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( + let slot = crate::identity::resolve_slot( self.client_fp, (mode.width, mode.height), - crate::vdisplay::policy::Identity::Shared, + crate::policy::Identity::Shared, ); self.last_slot = slot; // reported to the registry for the group arrangement + state slot let name = match slot { @@ -192,8 +192,8 @@ impl VirtualDisplay for KwinDisplay { // `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() { + use crate::policy::Topology; + let disabled = match crate::effective_topology() { Topology::Exclusive => apply_virtual_primary(&name), Topology::Primary => { apply_virtual_primary_only(&name); diff --git a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs similarity index 98% rename from crates/punktfunk-host/src/vdisplay/linux/mutter.rs rename to crates/pf-vdisplay/src/vdisplay/linux/mutter.rs index 82de2d36..539adc19 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs @@ -22,7 +22,7 @@ //! `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 +//! host persists the scale instead ([`identity::ScaleMap`](crate::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. @@ -72,7 +72,7 @@ pub struct MutterDisplay { /// 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)). + /// [`identity::ScaleMap`](crate::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 + @@ -123,17 +123,17 @@ impl VirtualDisplay for MutterDisplay { // 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.last_slot = crate::identity::resolve_slot( self.client_fp, (mode.width, mode.height), - crate::vdisplay::policy::Identity::Shared, + crate::policy::Identity::Shared, ); - let scale_key = crate::vdisplay::identity::scale_key( + let scale_key = crate::identity::scale_key( self.client_fp, (mode.width, mode.height), - crate::vdisplay::policy::Identity::Shared, + crate::policy::Identity::Shared, ); - let remembered_scale = crate::vdisplay::identity::scales() + let remembered_scale = crate::identity::scales() .lock() .unwrap() .get(&scale_key); @@ -195,7 +195,7 @@ impl Drop for StopGuard { /// 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)). +/// [`identity::ScaleMap`](crate::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 @@ -230,8 +230,8 @@ fn session_thread( // 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(); + use crate::policy::Topology; + let topo = crate::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 @@ -789,7 +789,7 @@ async fn persist_scale_change(dc: &zbus::Proxy<'_>, vconn: &str, scale_key: &str return; }; if (cur - *known).abs() > 1e-3 { - crate::vdisplay::identity::scales() + crate::identity::scales() .lock() .unwrap() .set(scale_key, cur); diff --git a/crates/punktfunk-host/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs similarity index 100% rename from crates/punktfunk-host/src/vdisplay/linux/wlroots.rs rename to crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/pf-vdisplay/src/vdisplay/policy.rs similarity index 99% rename from crates/punktfunk-host/src/vdisplay/policy.rs rename to crates/pf-vdisplay/src/vdisplay/policy.rs index f4d45bde..0fbdc741 100644 --- a/crates/punktfunk-host/src/vdisplay/policy.rs +++ b/crates/pf-vdisplay/src/vdisplay/policy.rs @@ -575,7 +575,7 @@ fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> { } /// 12 hex chars from the name + wall-clock nanos — collision-free in practice, no uuid dep (the -/// [`crate::library`] custom-entry id scheme). +/// the host `library` custom-entry id scheme). fn new_preset_id(name: &str) -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/pf-vdisplay/src/vdisplay/registry.rs similarity index 97% rename from crates/punktfunk-host/src/vdisplay/registry.rs rename to crates/pf-vdisplay/src/vdisplay/registry.rs index 6de4aa00..2ee80fc5 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/pf-vdisplay/src/vdisplay/registry.rs @@ -101,9 +101,11 @@ pub fn acquire( vd.create(mode) }; if out.is_ok() { - crate::events::emit(crate::events::EventKind::DisplayCreated { + crate::emit_display_event(crate::DisplayEvent::Created { backend: backend.to_string(), - mode: crate::events::mode_str(mode.width, mode.height, mode.refresh_hz), + width: mode.width, + height: mode.height, + refresh_hz: mode.refresh_hz, }); } out @@ -166,7 +168,7 @@ pub fn release(slot: Option) -> usize { 0 }; if released > 0 { - crate::events::emit(crate::events::EventKind::DisplayReleased { + crate::emit_display_event(crate::DisplayEvent::Released { count: released as u32, }); } @@ -223,9 +225,9 @@ mod linux { use anyhow::Result; use super::DisplayInfo; - use crate::vdisplay::lifecycle::{self, Release}; - use crate::vdisplay::policy::{self, Layout, Linger}; - use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput}; + use crate::lifecycle::{self, Release}; + use crate::policy::{self, Layout, Linger}; + use crate::{Mode, VirtualDisplay, VirtualOutput}; /// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the /// compositor output — and thus its PipeWire `node_id` — survives past the session), plus the @@ -345,7 +347,7 @@ mod linux { // now means nothing. gamescope spawns are exempt (`epoch_matches` — independent nested sessions). // An Active entry is left to its own session's capture-loss rebuild (which, under the bumped // epoch, won't reuse it); `invalidate_backend` clears a whole desktop backend on a known switch. - let cur_epoch = crate::vdisplay::session_epoch(); + let cur_epoch = crate::session_epoch(); let mut i = 0; while i < entries.len() { let dead_epoch = !epoch_matches(entries[i].backend, entries[i].epoch, cur_epoch) @@ -425,7 +427,7 @@ mod linux { // A2 reuse key: the launch command this acquire carries (a kept spawn running game A must never // be reused for a session launching game B). A4 reuse key: the current session epoch. let launch = vd.launch_command(); - let cur_epoch = crate::vdisplay::session_epoch(); + let cur_epoch = crate::session_epoch(); let r = reg(); // Reap expired first (run any group restores + drop outside the lock). @@ -552,7 +554,7 @@ mod linux { // owns their lifecycle (its own restore machinery), so the registry must not keep them // (the stale-node reuse wedge). Their unit keepalive tears nothing down on drop. // * `remote_fd = Some` — wlroots' sandboxed xdpw portal fd can't be re-opened per attach. - if real.ownership != crate::vdisplay::DisplayOwnership::Owned || real.remote_fd.is_some() { + if real.ownership != crate::DisplayOwnership::Owned || real.remote_fd.is_some() { tracing::debug!( backend, ownership = ?real.ownership, @@ -589,7 +591,7 @@ mod linux { // (rightmost under auto-row). `position_for_new` is pure; the lock is held only across it // (I/O-free) — the backend apply is below, outside the lock. let position = { - use crate::vdisplay::layout::Member; + use crate::layout::Member; let layout_policy = policy::prefs() .configured_effective() .map(|e| e.layout) @@ -761,15 +763,15 @@ mod linux { /// the pure [`layout`] engine, taking the new member's placement. Pure — so the append-in-acquire- /// order + auto-row/manual arrangement is unit-tested independent of the pool/global. fn position_for_new( - mut existing: Vec<(u64, crate::vdisplay::layout::Member)>, - new: crate::vdisplay::layout::Member, + mut existing: Vec<(u64, crate::layout::Member)>, + new: crate::layout::Member, layout_policy: &Layout, - ) -> crate::vdisplay::layout::Placement { + ) -> crate::layout::Placement { existing.sort_by_key(|(g, _)| *g); - let mut members: Vec = + let mut members: Vec = existing.into_iter().map(|(_, m)| m).collect(); members.push(new); - *crate::vdisplay::layout::arrange(&members, layout_policy) + *crate::layout::arrange(&members, layout_policy) .last() .expect("members is non-empty (just pushed `new`)") } @@ -796,7 +798,7 @@ mod linux { layout_policy: &Layout, topology: &str, ) -> Vec { - use crate::vdisplay::layout::{self, Member}; + use crate::layout::{self, Member}; // Small stable group ids by sorted group key — deterministic; in practice a host runs one live // desktop backend → group 1 (with each gamescope spawn its own group). @@ -977,7 +979,7 @@ mod linux { #[cfg(test)] mod tests { use super::*; - use crate::vdisplay::policy::{Layout, LayoutMode, Position}; + use crate::policy::{Layout, LayoutMode, Position}; use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -1146,7 +1148,7 @@ mod linux { #[test] fn position_for_new_appends_right_in_acquire_order() { - use crate::vdisplay::layout::{Member, Placement}; + use crate::layout::{Member, Placement}; let m = |slot, w| Member { identity_slot: slot, width: w, @@ -1163,7 +1165,7 @@ mod linux { #[test] fn position_for_new_honors_a_manual_pin() { - use crate::vdisplay::layout::{Member, Placement}; + use crate::layout::{Member, Placement}; let mut positions = BTreeMap::new(); positions.insert("5".to_string(), Position { x: 100, y: 200 }); let layout = Layout { diff --git a/crates/punktfunk-host/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs similarity index 100% rename from crates/punktfunk-host/src/vdisplay/routing.rs rename to crates/pf-vdisplay/src/vdisplay/routing.rs diff --git a/crates/punktfunk-host/src/vdisplay/session.rs b/crates/pf-vdisplay/src/vdisplay/session.rs similarity index 100% rename from crates/punktfunk-host/src/vdisplay/session.rs rename to crates/pf-vdisplay/src/vdisplay/session.rs diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs similarity index 97% rename from crates/punktfunk-host/src/vdisplay/windows/manager.rs rename to crates/pf-vdisplay/src/vdisplay/windows/manager.rs index 9243597f..650a0ed9 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -6,7 +6,7 @@ //! a **typed** [`OwnedHandle`] control device (no more raw `isize` smuggled across the pinger/linger //! threads). The backend differences — the IOCTL protocol and the per-monitor REMOVE key — are the only //! thing behind the [`VdisplayDriver`] seam; the state machine, the render-adapter pin decision, the -//! GDI/CCD glue (`crate::win_display`), and the generation-stamped [`MonitorLease`] are backend-neutral. +//! GDI/CCD glue (`pf_win_display::win_display`), and the generation-stamped [`MonitorLease`] are backend-neutral. //! //! It's a process-wide singleton ([`vdm`]) initialised once with the chosen backend's driver — the //! host runs exactly one virtual-display backend per process. The session holds a [`MonitorLease`]; @@ -33,7 +33,7 @@ use windows::Win32::System::Threading::{ }; use super::{DisplayOwnership, Mode, VirtualOutput}; -use crate::win_display::{ +use pf_win_display::win_display::{ count_other_active, force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig, }; @@ -45,7 +45,7 @@ pub(crate) use driver::{AddedMonitor, MonitorKey, VdisplayDriver}; #[path = "manager/instance.rs"] mod instance; use instance::claim_instance; -pub(crate) use instance::claim_instance_eagerly; +pub use instance::claim_instance_eagerly; #[path = "manager/knobs.rs"] mod knobs; @@ -89,11 +89,11 @@ struct Monitor { impl Monitor { /// The capture target handed to a session (`None` until the GDI name resolves on a WDDM GPU). - fn target(&self) -> Option { + fn target(&self) -> Option { self.gdi_name .clone() - .map(|n| crate::capture::dxgi::WinCaptureTarget { - adapter_luid: crate::capture::dxgi::pack_luid(self.luid), + .map(|n| pf_frame::dxgi::WinCaptureTarget { + adapter_luid: pf_frame::dxgi::pack_luid(self.luid), gdi_name: n, target_id: self.target_id, wudf_pid: self.wudf_pid, @@ -197,7 +197,7 @@ struct DeviceSlot { } /// The host-lifetime virtual-display manager: the single owner of the monitor lifecycle. -pub(crate) struct VirtualDisplayManager { +pub struct VirtualDisplayManager { driver: Box, /// Control device, opened on first acquire and REOPENED after a gone-classified failure retired /// it (see [`DeviceSlot`]). Typed + `Send+Sync`, so the pinger/linger threads share it via the @@ -244,7 +244,7 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa /// The process-wide manager. Panics if reached before a backend called [`init`] — by construction a /// session is only ever created after `vdisplay::open` constructed the backend (which calls `init`). -pub(crate) fn vdm() -> &'static VirtualDisplayManager { +pub fn vdm() -> &'static VirtualDisplayManager { VDM.get() .expect("VirtualDisplayManager used before a backend initialised it") } @@ -254,7 +254,7 @@ pub(crate) fn vdm() -> &'static VirtualDisplayManager { /// for the process lifetime — a dead one is RETIRED (kept alive, see [`DeviceSlot`]), so a stale copy /// can only fail IOCTLs, never dangle. `None` before the first backend open — impossible for a /// capturer, which only exists on a monitor the manager created. -pub(crate) fn control_device_handle() -> Option { +pub fn control_device_handle() -> Option { VDM.get().and_then(VirtualDisplayManager::device_handle) } @@ -384,7 +384,7 @@ impl VirtualDisplayManager { // session then dies far downstream as "no frame published within 4s" (the lid-closed field // report). Name the real problem up front, once per acquire. Non-fatal: the OS-side // persistence-DB activation can still succeed, so the attempt proceeds. - if let Some((own, console)) = crate::interactive::console_session_mismatch() { + if let Some((own, console)) = pf_win_display::console_session_mismatch() { tracing::error!( own_session = own, console_session = console, @@ -532,7 +532,7 @@ impl VirtualDisplayManager { // (`max_displays` across Active+Lingering+Pinned slots; the identity-slot ceiling of 15 is // the hard limit behind it) — this is the fail-closed backstop for a session that got past // admission anyway. One live slot can never trip it (max_displays >= 1). - let max = crate::vdisplay::policy::prefs() + let max = crate::policy::prefs() .get() .effective() .max_displays; @@ -659,11 +659,11 @@ impl VirtualDisplayManager { /// single member (it sits at the origin), so the single-display path issues no positioning at /// all. Records each monitor's applied position for the `/display/state` readout. fn apply_group_layout(&self, inner: &mut MgrInner) { - use crate::vdisplay::layout::{arrange, Member}; + use crate::layout::{arrange, Member}; if inner.slots.len() < 2 { return; } - let layout_policy = crate::vdisplay::policy::prefs().get().effective().layout; + let layout_policy = crate::policy::prefs().get().effective().layout; // Members in acquire (gen) order — the auto-row order; identity slot 0 = anonymous (no // manual pin can address it, so it always auto-rows). `(slot, gen, target_id, width)` // copied out so the arrangement below can write back through `get_mut`. @@ -691,7 +691,7 @@ impl VirtualDisplayManager { .collect(); // SAFETY: `apply_source_positions` only drives the CCD query/apply FFI with owned local // buffers, under the `state` lock — the sole topology mutator. - unsafe { crate::win_display::apply_source_positions(&positions) }; + unsafe { pf_win_display::win_display::apply_source_positions(&positions) }; for (&(slot, ..), p) in ordered.iter().zip(&placements) { if let Some( SlotState::Active { mon, .. } @@ -758,7 +758,7 @@ impl VirtualDisplayManager { } // SAFETY: `activate_target_path` runs the CCD query/apply FFI with owned local buffers; the // `Copy` target id is passed by value, under the `state` lock — the sole topology mutator. - if unsafe { crate::win_display::activate_target_path(target_id) } { + if unsafe { pf_win_display::win_display::activate_target_path(target_id) } { for _ in 0..15 { thread::sleep(Duration::from_millis(200)); // SAFETY: as the resolve loops above. @@ -831,7 +831,7 @@ impl VirtualDisplayManager { // group layout. `Extend` leaves it a plain extension. Both isolate + primary go // through the atomic CCD path (no MODE_CHANGE storm). Opt out (extend) with // PUNKTFUNK_NO_ISOLATE=1 / the console policy. - use crate::vdisplay::policy::Topology; + use crate::policy::Topology; let first_member = inner.slots.is_empty(); match topology_action() { Topology::Exclusive => { @@ -846,7 +846,7 @@ impl VirtualDisplayManager { // suspected source of the periodic sole-virtual-display stutter (the // rationale + evidence live in `windows/ddc.rs`). First member only: // the physicals are already dark for a sibling. - if crate::vdisplay::policy::prefs().ddc_power_off() { + if crate::policy::prefs().ddc_power_off() { inner.group.ddc_panels_off = crate::ddc::panel_off_except(n); } // SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it @@ -859,10 +859,10 @@ impl VirtualDisplayManager { // across hot-plug re-arrival) so a standby monitor/TV's periodic wake // events no longer trigger the Windows reaction cascade — the suspected // hiccup mechanism (rationale + crash journal in `windows/monitor_devnode.rs`). - if crate::vdisplay::policy::prefs().pnp_disable_monitors() { + if crate::policy::prefs().pnp_disable_monitors() { if let Some(saved) = &inner.group.ccd_saved { inner.group.pnp_disabled = - crate::monitor_devnode::disable_for_deactivated( + pf_win_display::monitor_devnode::disable_for_deactivated( saved, added.target_id, ); @@ -934,10 +934,10 @@ impl VirtualDisplayManager { // inactive and get disabled); in Extend the active physical panels are untouched // by construction. First member only — the sweep is group-scoped like the // isolate; later members join an already-swept desktop. - if first_member && crate::vdisplay::policy::prefs().pnp_disable_monitors() { + if first_member && crate::policy::prefs().pnp_disable_monitors() { let mut keep = inner.target_ids(); keep.push(added.target_id); - for id in crate::monitor_devnode::disable_connected_inactive(&keep) { + for id in pf_win_display::monitor_devnode::disable_connected_inactive(&keep) { if !inner.group.pnp_disabled.contains(&id) { inner.group.pnp_disabled.push(id); } @@ -1078,7 +1078,7 @@ impl VirtualDisplayManager { /// # Safety /// Drives the CCD topology FFI; call under the `state` lock. unsafe fn reisolate_after_swap(&self, inner: &mut MgrInner, new_target: u32) { - use crate::vdisplay::policy::Topology; + use crate::policy::Topology; match topology_action() { Topology::Exclusive => { // Grown-set semantics: isolate to the surviving siblings + the new target. The returned @@ -1145,7 +1145,7 @@ impl VirtualDisplayManager { // Extend/Primary sessions too, where no isolate snapshot exists to restore. let pnp_disabled = std::mem::take(&mut inner.group.pnp_disabled); if !pnp_disabled.is_empty() { - crate::monitor_devnode::enable_instances(&pnp_disabled); + pf_win_display::monitor_devnode::enable_instances(&pnp_disabled); thread::sleep(Duration::from_millis(300)); } // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero @@ -1306,7 +1306,7 @@ impl VirtualDisplayManager { /// monitor is created. Slot-scoped since Stage W1: a DIFFERENT identity's session is an admission /// question, never a preempt. Returns the setup guard; the caller holds it across the pipeline /// build, then drops it so the next reconnect can begin (and preempt this one). - pub(crate) fn begin_idd_setup( + pub fn begin_idd_setup( &'static self, slot: u32, stop: Arc, @@ -1449,11 +1449,11 @@ impl Drop for MonitorLease { /// anonymous slot at a time — exactly the pre-slot-map semantics, since an anonymous re-acquire has /// no identity to find any other slot by). Shared by `acquire` and the session setup's /// [`VirtualDisplayManager::begin_idd_setup`], so both address the same slot. -pub(crate) fn slot_id_for(client_fp: Option<[u8; 32]>, mode: (u32, u32)) -> u32 { +pub fn slot_id_for(client_fp: Option<[u8; 32]>, mode: (u32, u32)) -> u32 { super::identity::resolve_slot( client_fp, mode, - crate::vdisplay::policy::Identity::PerClient, + crate::policy::Identity::PerClient, ) .unwrap_or(0) } @@ -1500,7 +1500,7 @@ fn warn_if_pick_moved(mon: &Monitor) { } /// A read-only view of one managed slot for the mgmt `/display/state` endpoint (Goal: -/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade +/// display-management registry facade). Backend-neutral; the [`crate::registry`] facade /// maps it into the wire shape. pub(crate) struct ManagedInfo { pub backend: &'static str, diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager/driver.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs similarity index 100% rename from crates/punktfunk-host/src/vdisplay/windows/manager/driver.rs rename to crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager/instance.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/instance.rs similarity index 98% rename from crates/punktfunk-host/src/vdisplay/windows/manager/instance.rs rename to crates/pf-vdisplay/src/vdisplay/windows/manager/instance.rs index dd587322..5c85846e 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager/instance.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/instance.rs @@ -23,7 +23,7 @@ pub(super) fn claim_instance() -> Result<()> { /// 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() { +pub fn claim_instance_eagerly() { if let Err(e) = claim_instance() { tracing::warn!("pf-vdisplay single-instance claim failed at startup: {e:#}"); } diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager/knobs.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/knobs.rs similarity index 80% rename from crates/punktfunk-host/src/vdisplay/windows/manager/knobs.rs rename to crates/pf-vdisplay/src/vdisplay/windows/manager/knobs.rs index 0d860afe..928bda11 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager/knobs.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/knobs.rs @@ -1,12 +1,12 @@ //! 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. +//! per-monitor topology action. Pure readers of [`crate::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}; + use crate::policy::{prefs, Linger}; if let Some(eff) = prefs().configured_effective() { return match eff.keep_alive.linger() { Linger::Immediate => 0, @@ -27,7 +27,7 @@ pub(super) fn linger_ms() -> u64 { /// 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}; + use crate::policy::{prefs, Linger}; prefs() .configured_effective() .map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever)) @@ -35,17 +35,17 @@ pub(super) fn keep_alive_forever() -> bool { } /// 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 +/// [`effective_topology`](crate::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() +pub(super) fn topology_action() -> crate::policy::Topology { + use crate::policy::Topology; + if crate::policy::prefs() .configured_effective() .is_some() { - return crate::vdisplay::effective_topology(); + return crate::effective_topology(); } if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() { Topology::Extend diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs similarity index 96% rename from crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs rename to crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs index a34026dc..cefa9716 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs @@ -217,7 +217,7 @@ unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> { /// /// # Safety /// `dev` must be a live pf-vdisplay control handle (see [`super::manager::control_device_handle`]). -pub(crate) unsafe fn send_frame_channel( +pub unsafe fn send_frame_channel( dev: HANDLE, req: &control::SetFrameChannelRequest, ) -> Result<()> { @@ -386,8 +386,18 @@ impl VdisplayDriver for PfVdisplayDriver { // takes no input (`&[]`) and writes into `info_buf`, a stack `[u8; size_of::()]` // 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) } + let n = unsafe { ioctl(raw, control::IOCTL_GET_INFO, &[], &mut info_buf) } .context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?; + // Fail closed on a short driver reply instead of decoding trusted-looking zeros — the decoded + // `protocol_version` (and below, the ADD reply's pid/luid/target) gate host behavior, so a + // buggy/compromised driver under-writing the buffer must not be silently trusted + // (security-review 2026-07-17). + if (n as usize) < size_of::() { + anyhow::bail!( + "pf-vdisplay IOCTL_GET_INFO returned {n} bytes, expected {}", + size_of::() + ); + } let info: control::InfoReply = bytemuck::pod_read_unaligned(&info_buf[..size_of::()]); if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION { @@ -520,12 +530,20 @@ impl VdisplayDriver for PfVdisplayDriver { } other => other, }; - add_res.with_context(|| { + let n = add_res.with_context(|| { format!( "pf-vdisplay ADD {}x{}@{}", mode.width, mode.height, mode.refresh_hz ) })?; + // Fail closed on a short reply — `target_id`/`wudf_pid`/`luid` below feed OpenProcess + the + // WUDFHost verification, so don't decode a partially-written (zeroed) reply as authoritative. + if (n as usize) < size_of::() { + anyhow::bail!( + "pf-vdisplay ADD returned {n} bytes, expected {}", + size_of::() + ); + } // `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 = diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 3964b2f8..8848f14d 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -38,6 +38,9 @@ pf-capture = { path = "../pf-capture" } # subsystem crate (plan §W6). Consumes core::input; the heavy input deps (wayland/reis/xkbcommon/ # usbip) moved with it. pf-inject = { path = "../pf-inject" } +# Virtual-display orchestration (per-compositor Linux backends + the Windows IddCx driver backend), +# extracted to a subsystem crate (plan §W6). The DDC panel control + KWin zkde protocol moved with it. +pf-vdisplay = { path = "../pf-vdisplay" } # M3 native control plane (the `punktfunk/1` QUIC handshake; data plane stays native-thread UDP). quinn = "0.11" anyhow = "1" diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index e25204c5..e6758c40 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -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, ) diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 1a88ff08..5c5dad74 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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