feat(vdisplay): display-management policy surface (Stage 0)
A user-configurable policy layer above the per-compositor VirtualDisplay backends: keep-alive, topology, conflict, identity, layout, max-displays — persisted to display-settings.json, editable from the web console, applied per connect. Design: design/display-management.md. Stage 0 stands up the surface and wires the two behaviors the existing code can already express — the Windows monitor linger duration and the "make the streamed output the sole desktop" topology — through it; every other option is stored + echoed but not yet enforced (later stages). An unconfigured host (no display-settings.json) keeps today's exact behavior. - vdisplay/policy.rs: pure DisplayPolicy + 5 presets + JSON store (gpu-settings pattern) + EffectivePolicy; 9 unit tests. - vdisplay.rs: resolve_topology(Auto); apply_session_env drives *_VIRTUAL_PRIMARY from the policy only when a settings file exists. - windows/manager.rs: linger_ms() + should_isolate() read the policy when configured. - mgmt: GET/PUT /api/v1/display/settings (bearer-only); PUT rejects keep_alive forever until the lifecycle stage. OpenAPI regenerated. - web console: Host → Virtual displays card (preset picker + custom fields); en+de. - docs-site: virtual-displays.md + configuration.md cross-links. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -156,6 +156,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(list_compositors))
|
||||
.routes(routes!(list_gpus))
|
||||
.routes(routes!(set_gpu_preference))
|
||||
.routes(routes!(get_display_settings))
|
||||
.routes(routes!(set_display_settings))
|
||||
.routes(routes!(get_status))
|
||||
.routes(routes!(get_local_summary))
|
||||
.routes(routes!(list_paired_clients))
|
||||
@@ -210,6 +212,7 @@ pub fn openapi_json() -> String {
|
||||
tags(
|
||||
(name = "host", description = "Host identity, capabilities, and liveness"),
|
||||
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
|
||||
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
|
||||
(name = "clients", description = "Paired Moonlight client management"),
|
||||
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
|
||||
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
|
||||
@@ -954,6 +957,144 @@ async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response
|
||||
Json(gpu_state()).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Display management (design/display-management.md)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// One preset's human-facing description + the fields it expands to, so the console can render a
|
||||
/// preset picker with an accurate "what this does" preview without hardcoding the expansion.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct PresetInfo {
|
||||
/// The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).
|
||||
id: String,
|
||||
/// One-line story shown next to the option.
|
||||
summary: String,
|
||||
/// The effective policy this preset expands to (the same fields a `custom` policy carries).
|
||||
fields: crate::vdisplay::policy::EffectivePolicy,
|
||||
}
|
||||
|
||||
/// Full display-management state for the console: the stored policy, every preset's expansion, the
|
||||
/// resolved effective policy, and which options this build actually enforces yet (Stage 0 wires
|
||||
/// keep-alive linger + topology; the rest are stored but not yet acted on).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct DisplaySettingsState {
|
||||
/// The stored policy (preset + custom fields), or the built-in default when unconfigured.
|
||||
settings: crate::vdisplay::policy::DisplayPolicy,
|
||||
/// True once a `display-settings.json` exists (the console has configured this host).
|
||||
configured: bool,
|
||||
/// The effective (preset-expanded) policy currently in force.
|
||||
effective: crate::vdisplay::policy::EffectivePolicy,
|
||||
/// Every named preset and what it expands to (for the picker's preview).
|
||||
presets: Vec<PresetInfo>,
|
||||
/// Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining
|
||||
/// stored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the
|
||||
/// console can mark them "coming soon" instead of implying they already take effect.
|
||||
enforced: Vec<String>,
|
||||
}
|
||||
|
||||
fn preset_summary(id: &str) -> &'static str {
|
||||
match id {
|
||||
"default" => "Today's behavior: a short linger absorbs reconnects, the streamed output is the sole desktop, extra clients get their own view.",
|
||||
"gaming-rig" => "Dedicated couch/headless box: the game and its display survive disconnects; whoever connects takes the box over.",
|
||||
"shared-desktop" => "A desktop you also use in person: never blank the real monitors, never keep ghost displays, concurrent viewers each get a view.",
|
||||
"hotdesk" => "One user at a time with fast reattach; a second user is told the box is busy; each device+resolution keeps its own scaling.",
|
||||
"workstation" => "Multi-monitor daily driver: your displays come back exactly where you arranged them, per-client identity, exclusive.",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn display_settings_state() -> DisplaySettingsState {
|
||||
use crate::vdisplay::policy::{self, Preset};
|
||||
let store = policy::prefs();
|
||||
let settings = store.get();
|
||||
let configured = store.configured().is_some();
|
||||
let presets = [
|
||||
("default", Preset::Default),
|
||||
("gaming-rig", Preset::GamingRig),
|
||||
("shared-desktop", Preset::SharedDesktop),
|
||||
("hotdesk", Preset::Hotdesk),
|
||||
("workstation", Preset::Workstation),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(id, p)| {
|
||||
policy::preset_fields(p).map(|e| PresetInfo {
|
||||
id: id.to_string(),
|
||||
summary: preset_summary(id).to_string(),
|
||||
fields: e,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
DisplaySettingsState {
|
||||
effective: settings.effective(),
|
||||
settings,
|
||||
configured,
|
||||
presets,
|
||||
enforced: vec!["keep_alive".into(), "topology".into()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Display-management policy
|
||||
///
|
||||
/// The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),
|
||||
/// every preset's expansion, and which options this build enforces yet. See
|
||||
/// `design/display-management.md`.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/display/settings",
|
||||
tag = "display",
|
||||
operation_id = "getDisplaySettings",
|
||||
responses(
|
||||
(status = OK, description = "Stored policy + preset expansions + enforced options", body = DisplaySettingsState),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn get_display_settings() -> Json<DisplaySettingsState> {
|
||||
Json(display_settings_state())
|
||||
}
|
||||
|
||||
/// Set the display-management policy
|
||||
///
|
||||
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
|
||||
/// running session keeps the display it opened on. `keep_alive: forever` is rejected until the
|
||||
/// display-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release
|
||||
/// path yet).
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/display/settings",
|
||||
tag = "display",
|
||||
operation_id = "setDisplaySettings",
|
||||
request_body = crate::vdisplay::policy::DisplayPolicy,
|
||||
responses(
|
||||
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
|
||||
(status = BAD_REQUEST, description = "An option value is not yet supported (e.g. keep_alive forever)", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
async fn set_display_settings(
|
||||
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
|
||||
) -> Response {
|
||||
use crate::vdisplay::policy::KeepAlive;
|
||||
// Reject options this build can't honor yet, so the console can't promise a behavior that won't
|
||||
// happen. `keep_alive: forever` (directly or via the `gaming-rig` preset) needs the Pinned
|
||||
// lifecycle + a release path; until then it would strand physical monitors dark.
|
||||
if policy.effective().keep_alive == KeepAlive::Forever {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"keep_alive `forever` (and the `gaming-rig` preset) is not available yet — it arrives \
|
||||
with the display-lifecycle stage. Use a fixed duration for now.",
|
||||
);
|
||||
}
|
||||
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
|
||||
return api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("persist display policy: {e:#}"),
|
||||
);
|
||||
}
|
||||
tracing::info!("management API: display policy updated");
|
||||
Json(display_settings_state()).into_response()
|
||||
}
|
||||
|
||||
/// Live host status
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -2473,6 +2614,47 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The display-management endpoints: GET returns the policy surface (presets + effective +
|
||||
/// the Stage-0 enforced list); PUT rejects `keep_alive: forever` (the `gaming-rig` preset)
|
||||
/// *before* persisting, so this stays read-only against the global policy store.
|
||||
#[tokio::test]
|
||||
async fn display_settings_surface_and_forever_rejected() {
|
||||
let app = test_app(test_state(), None);
|
||||
|
||||
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
body["presets"].as_array().map(|a| a.len()),
|
||||
Some(5),
|
||||
"all five named presets are surfaced for the console picker"
|
||||
);
|
||||
assert!(
|
||||
body["effective"]["keep_alive"].is_object(),
|
||||
"the effective policy is echoed"
|
||||
);
|
||||
let enforced: Vec<&str> = body["enforced"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
assert!(enforced.contains(&"keep_alive") && enforced.contains(&"topology"));
|
||||
|
||||
// `gaming-rig` expands to keep_alive: forever → rejected at Stage 0 (before any write).
|
||||
let put = axum::http::Request::put("/api/v1/display/settings")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({ "preset": "gaming-rig" }).to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, body) = send(&app, put).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
body["error"].as_str().unwrap_or_default().contains("forever"),
|
||||
"the rejection names the unsupported option"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_pairing_arm_show_and_unpair() {
|
||||
let np = Arc::new(
|
||||
|
||||
@@ -405,18 +405,41 @@ pub fn apply_session_env(active: &ActiveSession) {
|
||||
}
|
||||
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so
|
||||
// the panels + windows land on the streamed surface, not an unstreamed real output (the
|
||||
// auto-detected desktop path *is* "stream this desktop"). Default-on for the auto path; an
|
||||
// explicit `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` still wins.
|
||||
match active.kind {
|
||||
ActiveKind::DesktopKde if std::env::var_os("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY").is_none() => {
|
||||
std::env::set_var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY", "1");
|
||||
// auto-detected desktop path *is* "stream this desktop"). The per-compositor backends read
|
||||
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`; drive it here from the display-management topology.
|
||||
//
|
||||
// Stage 0 keeps today's behavior exactly UNLESS the console configured a policy: when a
|
||||
// `display-settings.json` exists, the effective topology wins (Exclusive → sole desktop,
|
||||
// Extend → leave the streamed output extended, Primary → treated as Exclusive until the
|
||||
// primary-only path lands in the topology stage). Unconfigured hosts fall through to the
|
||||
// historical default-on-for-desktop behavior, honoring an explicit operator env var.
|
||||
let var = match active.kind {
|
||||
ActiveKind::DesktopKde => Some("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY"),
|
||||
ActiveKind::DesktopGnome => Some("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(var) = var {
|
||||
match policy::prefs().configured_effective() {
|
||||
Some(eff) => {
|
||||
let sole = match resolve_topology(eff.topology) {
|
||||
policy::Topology::Extend => false,
|
||||
policy::Topology::Exclusive => true,
|
||||
policy::Topology::Primary => {
|
||||
tracing::info!(
|
||||
"display policy: topology=primary treated as exclusive at this stage \
|
||||
(primary-only lands in the topology stage)"
|
||||
);
|
||||
true
|
||||
}
|
||||
// resolve_topology never returns Auto.
|
||||
policy::Topology::Auto => true,
|
||||
};
|
||||
std::env::set_var(var, if sole { "1" } else { "0" });
|
||||
}
|
||||
// Unconfigured: today's behavior — default-on unless the operator set it explicitly.
|
||||
None if std::env::var_os(var).is_none() => std::env::set_var(var, "1"),
|
||||
None => {}
|
||||
}
|
||||
ActiveKind::DesktopGnome
|
||||
if std::env::var_os("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY").is_none() =>
|
||||
{
|
||||
std::env::set_var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY", "1");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -723,6 +746,29 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
std::sync::Arc::new(())
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
/// 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 crate::config::config().compositor.is_some() {
|
||||
policy::Topology::Extend
|
||||
} else {
|
||||
policy::Topology::Exclusive
|
||||
}
|
||||
}
|
||||
concrete => concrete,
|
||||
}
|
||||
}
|
||||
|
||||
// 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")]
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! 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;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [`crate::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())
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
crate::gamestream::create_private_dir(dir)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
crate::gamestream::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 [`crate::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(crate::gamestream::config_dir().join("display-settings.json"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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 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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -634,13 +634,15 @@ impl VirtualDisplayManager {
|
||||
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
|
||||
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
|
||||
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
||||
if should_isolate() {
|
||||
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a
|
||||
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed
|
||||
// memory crosses). It runs under the `state` lock, the sole mutator of the topology.
|
||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||
} else {
|
||||
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
|
||||
tracing::info!(
|
||||
"display isolation skipped (topology=extend / PUNKTFUNK_NO_ISOLATE) — IDD stays extended"
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
@@ -890,10 +892,44 @@ fn resolve_render_pin() -> Option<LUID> {
|
||||
crate::win_adapter::resolve_render_adapter_luid()
|
||||
}
|
||||
|
||||
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
|
||||
/// 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.
|
||||
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,
|
||||
// Pinned (keep forever) is built in the display-lifecycle stage; until then fall back to
|
||||
// the default rather than silently keeping the monitor — and thus the physical screens —
|
||||
// dark indefinitely. (The mgmt PUT also rejects `forever` at Stage 0, so this is defensive.)
|
||||
Linger::Forever => {
|
||||
tracing::warn!(
|
||||
"display policy: keep_alive=forever not yet honored — lingering 10 s \
|
||||
(Pinned lands in the display-lifecycle stage)"
|
||||
);
|
||||
10_000
|
||||
}
|
||||
};
|
||||
}
|
||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000)
|
||||
}
|
||||
|
||||
/// Should a freshly-created monitor isolate the desktop to itself (disable the other displays)? The
|
||||
/// console policy's effective topology wins when configured — `Extend` leaves the IDD extended,
|
||||
/// `Exclusive`/`Primary` isolate (Stage 0 treats `Primary` as `Exclusive`); otherwise the legacy
|
||||
/// `PUNKTFUNK_NO_ISOLATE` env knob (unset ⇒ isolate, matching today's default).
|
||||
fn should_isolate() -> bool {
|
||||
use crate::vdisplay::policy::Topology;
|
||||
if let Some(eff) = crate::vdisplay::policy::prefs().configured_effective() {
|
||||
return !matches!(
|
||||
crate::vdisplay::resolve_topology(eff.topology),
|
||||
Topology::Extend
|
||||
);
|
||||
}
|
||||
std::env::var("PUNKTFUNK_NO_ISOLATE").is_err()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user