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:
2026-07-04 19:44:18 +00:00
parent 2a9b9d83c6
commit 21f3c1326f
13 changed files with 1687 additions and 19 deletions
+371 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.6.0"
"version": "0.7.4"
},
"paths": {
"/api/v1/clients": {
@@ -138,6 +138,98 @@
}
}
},
"/api/v1/display/settings": {
"get": {
"tags": [
"display"
],
"summary": "Display-management policy",
"description": "The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),\nevery preset's expansion, and which options this build enforces yet. See\n`design/display-management.md`.",
"operationId": "getDisplaySettings",
"responses": {
"200": {
"description": "Stored policy + preset expansions + enforced options",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"put": {
"tags": [
"display"
],
"summary": "Set the display-management policy",
"description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` is rejected until the\ndisplay-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release\npath yet).",
"operationId": "setDisplaySettings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayPolicy"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Policy stored; the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"400": {
"description": "An option value is not yet supported (e.g. keep_alive forever)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Policy could not be persisted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/gpus": {
"get": {
"tags": [
@@ -1909,6 +2001,115 @@
}
}
},
"DisplayPolicy": {
"type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"preset": {
"$ref": "#/components/schemas/Preset"
},
"topology": {
"$ref": "#/components/schemas/Topology"
},
"version": {
"type": "integer",
"format": "int32",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
"minimum": 0
}
}
},
"DisplaySettingsState": {
"type": "object",
"description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).",
"required": [
"settings",
"configured",
"effective",
"presets",
"enforced"
],
"properties": {
"configured": {
"type": "boolean",
"description": "True once a `display-settings.json` exists (the console has configured this host)."
},
"effective": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective (preset-expanded) policy currently in force."
},
"enforced": {
"type": "array",
"items": {
"type": "string"
},
"description": "Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining\nstored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the\nconsole can mark them \"coming soon\" instead of implying they already take effect."
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PresetInfo"
},
"description": "Every named preset and what it expands to (for the picker's preview)."
},
"settings": {
"$ref": "#/components/schemas/DisplayPolicy",
"description": "The stored policy (preset + custom fields), or the built-in default when unconfigured."
}
}
},
"EffectivePolicy": {
"type": "object",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
"required": [
"keep_alive",
"topology",
"mode_conflict",
"identity",
"layout",
"max_displays"
],
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"topology": {
"$ref": "#/components/schemas/Topology"
}
}
},
"GameEntry": {
"type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
@@ -2099,6 +2300,72 @@
}
}
},
"Identity": {
"type": "string",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
"enum": [
"shared",
"per-client",
"per-client-mode"
]
},
"KeepAlive": {
"oneOf": [
{
"type": "object",
"description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s).",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"off"
]
}
}
},
{
"type": "object",
"description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.",
"required": [
"seconds",
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"duration"
]
},
"seconds": {
"type": "integer",
"format": "int32",
"description": "Linger window in seconds.",
"minimum": 0
}
}
},
{
"type": "object",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"forever"
]
}
}
}
],
"description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple."
},
"LaunchSpec": {
"type": "object",
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
@@ -2118,6 +2385,32 @@
}
}
},
"Layout": {
"type": "object",
"description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).",
"properties": {
"mode": {
"$ref": "#/components/schemas/LayoutMode"
},
"positions": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
"propertyNames": {
"type": "string"
}
}
}
},
"LayoutMode": {
"type": "string",
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
"enum": [
"auto-row",
"manual"
]
},
"LocalSummary": {
"type": "object",
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
@@ -2242,6 +2535,16 @@
}
}
},
"ModeConflict": {
"type": "string",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
"enum": [
"separate",
"steal",
"join",
"reject"
]
},
"NativeClient": {
"type": "object",
"description": "A paired native (punktfunk/1) client.",
@@ -2439,6 +2742,59 @@
}
}
},
"Position": {
"type": "object",
"description": "A desktop-space offset for a display (top-left origin).",
"required": [
"x",
"y"
],
"properties": {
"x": {
"type": "integer",
"format": "int32"
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"Preset": {
"type": "string",
"description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).",
"enum": [
"custom",
"default",
"gaming-rig",
"shared-desktop",
"hotdesk",
"workstation"
]
},
"PresetInfo": {
"type": "object",
"description": "One preset's human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.",
"required": [
"id",
"summary",
"fields"
],
"properties": {
"fields": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective policy this preset expands to (the same fields a `custom` policy carries)."
},
"id": {
"type": "string",
"description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)."
},
"summary": {
"type": "string",
"description": "One-line story shown next to the option."
}
}
},
"RuntimeStatus": {
"type": "object",
"description": "Live host status (changes as clients launch/end sessions).",
@@ -2740,6 +3096,16 @@
"example": "1234"
}
}
},
"Topology": {
"type": "string",
"description": "What the host does to the box's display topology while managed virtual displays are up.",
"enum": [
"auto",
"extend",
"primary",
"exclusive"
]
}
},
"securitySchemes": {
@@ -2763,6 +3129,10 @@
"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"
+182
View File
@@ -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(
+57 -11
View File
@@ -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()
}
+8 -2
View File
@@ -62,9 +62,15 @@ picture.
## Compositor-specific (Linux)
> **Managing virtual displays** — keep-alive after disconnect, exclusive vs. extend, and (on
> Windows/KDE) persistent per-client scaling — now has its own settings surface in the web console
> and `display-settings.json`. See [Virtual displays](/docs/virtual-displays). The two
> `*_VIRTUAL_PRIMARY` knobs and `PUNKTFUNK_MONITOR_LINGER_MS` below still work but are superseded by
> it (a settings file wins over them).
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
| `PUNKTFUNK_MUTTER_VIRTUAL_REFRESH` | `1` | Pin the client's exact WxH**@Hz** via `RecordVirtual`'s custom modes (needed for >60 Hz on Mutter). |
@@ -99,7 +105,7 @@ picture.
|---|---|---|
| `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. |
| `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). |
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
+1
View File
@@ -24,6 +24,7 @@
"pairing",
"---Configuration---",
"configuration",
"virtual-displays",
"host-cli",
"---Troubleshooting---",
"troubleshooting",
+133
View File
@@ -0,0 +1,133 @@
---
title: Virtual displays
description: Control how punktfunk creates, keeps alive, and arranges the virtual displays it streams — presets, keep-alive, exclusive vs. extend, and persistent per-client scaling.
---
When a client connects, punktfunk creates a **virtual display** sized to exactly that client's
resolution and refresh, renders your desktop or game onto it, and streams it. This page is about the
**policy** for that display: how long it survives a disconnect, whether it takes over your physical
monitors, what happens when a second client connects, and how desktop environments remember
per-client settings like scaling.
You set this policy in the **web console** (Host → *Virtual displays*), or by editing
`~/.config/punktfunk/display-settings.json` directly (`%ProgramData%\punktfunk\display-settings.json`
on Windows). A change applies to the **next** connection — a running session keeps the display it
opened on.
> **You rarely need to touch this.** The default behavior matches how punktfunk has always worked.
> Reach for a preset when you want a specific experience — a dedicated couch/gaming box, a desktop
> you also use in person, or a multi-monitor workstation.
> **What's live today:** this release wires **keep-alive** (linger duration) and **topology**
> (extend / primary / exclusive). The other options below — conflict handling, identity/scaling
> persistence on Linux, and multi-monitor layout — are **stored but not yet enforced**; they arrive
> in following releases. The console marks them accordingly. Windows already persists per-client
> scaling (see [Persistent scaling](#persistent-scaling)).
## Pick a preset
A preset is the easy way in — select one in the console and you're done. Each expands to a bundle of
the individual options documented further down.
| Preset | What it's for |
|---|---|
| **Default** | Today's behavior. A short linger absorbs reconnects, the streamed output becomes the sole desktop, and extra clients each get their own view. |
| **Gaming rig** | A dedicated couch/headless box. The game and its display survive disconnects indefinitely, and whoever connects takes the box over. *(Arrives with the keep-alive stage.)* |
| **Shared desktop** | A desktop you also use in person. punktfunk never blanks your real monitors and never leaves a ghost display behind; concurrent viewers each get a view. |
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
## Options reference
Choose **Custom** in the console to set these directly.
### Keep alive
How long the virtual display survives after your last session disconnects. On a gamescope game host,
this also keeps the **game itself running** so you can reconnect straight back into it.
- **Off** — tear the display down at session end (nothing lingers).
- **A duration** (seconds) — keep it for that long; a reconnect inside the window drops you straight
back in, with no re-negotiation and no desktop reshuffle.
- **Forever** — keep it until you stop the host or release it from the console. *(Arrives with the
keep-alive lifecycle stage; the console won't let you save it before then.)*
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
immediately — a short linger makes reconnects smoother on both.
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
> linger expires or you release the display. That's intentional for a dedicated gaming box, but
> don't set a long/forever keep-alive together with Exclusive on a machine whose monitors you also
> use in person — use **Shared desktop** there instead.
### Topology
What punktfunk does with your monitor layout while it streams.
- **Extend** — add the virtual display alongside your real monitors; touch nothing else.
- **Primary** — make the virtual display your primary output; your physical monitors stay on.
- **Exclusive** — the virtual display becomes your **only** enabled output (physical monitors are
disabled, then restored when streaming ends). This is what makes the streamed surface *be* the
desktop, so panels and windows land on it.
- **Automatic** *(default)* — Exclusive on Windows and on an auto-detected KDE/GNOME desktop
("stream this desktop" means the streamed output *is* the desktop); Extend when you've pinned a
specific compositor with `PUNKTFUNK_COMPOSITOR` (a test/CI posture).
Per-backend support:
| | KWin | Mutter/GNOME | Sway/wlroots | Windows |
|---|---|---|---|---|
| Extend | ✅ | ✅ | ✅ | ✅ |
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ *(following release)* |
| Exclusive | ✅ | ✅ | ✅ *(following release)* | ✅ |
### Conflict handling · identity · layout
These are **stored but not yet enforced** — they're documented here so you know what's coming and
can set them ahead of the release that turns them on:
- **Conflict handling** — what happens when a *different* client connects while one is already
streaming and asks for a different resolution: give it its own display (**separate**), take the
box over (**steal**), share the existing display at its current mode (**join**), or refuse it
(**reject**).
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
remembers its settings (see below): one shared identity, one **per client**, or one **per client +
resolution**.
- **Layout / max displays** — how multiple virtual displays are arranged (for multi-monitor), and an
upper bound on how many can be live at once.
## Persistent scaling
Set your display **scaling** once and have it stick across reconnects. This works by giving each
client a *stable display identity*, so your desktop environment keys its per-monitor settings to it.
| Host | Supported | How |
|---|---|---|
| **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. |
| **KDE / KWin** | ⏳ following release | A stable per-client output name lets KWin persist scale/mode per client. |
| **GNOME / Mutter** | ❌ | GNOME's virtual-monitor API exposes no stable identity to key config on. |
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
## Legacy environment knobs
These `PUNKTFUNK_*` variables still work, but the console (and `display-settings.json`) supersede
them — when a settings file exists, it wins.
| Legacy knob | Now expressed as |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | **Keep alive** → duration *(Windows)* |
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
## Troubleshooting
**My physical monitors stayed off after I disconnected.** You have keep-alive set together with
Exclusive topology — the display (and your isolated desktop) is being kept for the linger window.
Release it from the console (Host → *Virtual displays*), or switch to the **Shared desktop** preset
so streaming never disables your real monitors.
**The virtual output shows only my wallpaper.** Your topology is Extend, so the streamed display is
an empty extension. Use **Primary** or **Exclusive** so your desktop actually lands on it.
**KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all —
see [requirements](/docs/requirements).
+21
View File
@@ -47,6 +47,27 @@
"gpu_none": "Keine GPUs erkannt.",
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"host_displays": "Virtuelle Displays",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_preset": "Voreinstellung",
"display_preset_custom": "Benutzerdefiniert",
"display_preset_default": "Standard",
"display_preset_gaming_rig": "Gaming-Rig",
"display_preset_shared_desktop": "Geteilter Desktop",
"display_preset_hotdesk": "Hot-Desk",
"display_preset_workstation": "Workstation",
"display_keep_alive": "Nach Trennung aktiv halten",
"display_keep_alive_off": "Aus",
"display_keep_alive_seconds": "Sekunden",
"display_topology": "Topologie",
"display_topology_auto": "Automatisch",
"display_topology_extend": "Erweitern",
"display_topology_primary": "Primär",
"display_topology_exclusive": "Exklusiv",
"display_max": "Max. Displays",
"display_save": "Speichern",
"display_effective": "Aktiv",
"display_pending_note": "Konfliktbehandlung, Identität und Layout werden gespeichert, aber noch nicht angewendet — sie folgen in späteren Versionen.",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
+21
View File
@@ -47,6 +47,27 @@
"gpu_none": "No GPUs detected.",
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"host_displays": "Virtual displays",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_preset": "Preset",
"display_preset_custom": "Custom",
"display_preset_default": "Default",
"display_preset_gaming_rig": "Gaming rig",
"display_preset_shared_desktop": "Shared desktop",
"display_preset_hotdesk": "Hot-desk",
"display_preset_workstation": "Workstation",
"display_keep_alive": "Keep alive after disconnect",
"display_keep_alive_off": "Off",
"display_keep_alive_seconds": "seconds",
"display_topology": "Topology",
"display_topology_auto": "Automatic",
"display_topology_extend": "Extend",
"display_topology_primary": "Primary",
"display_topology_exclusive": "Exclusive",
"display_max": "Max displays",
"display_save": "Save",
"display_effective": "In effect",
"display_pending_note": "Conflict handling, identity, and layout are stored but not enforced yet — they arrive in later releases.",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
+269
View File
@@ -0,0 +1,269 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { type FC, useEffect, useState } from "react";
import {
getGetDisplaySettingsQueryKey,
useGetDisplaySettings,
useSetDisplaySettings,
} from "@/api/gen/display/display";
import { ApiError } from "@/api/fetcher";
import type {
DisplayPolicy,
EffectivePolicy,
KeepAlive,
Preset,
Topology,
} from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/**
* Container: the host's virtual-display management policy (design/display-management.md). Reads the
* stored policy + preset expansions, lets the operator pick a preset or set Custom fields, and PUTs
* the result — a change applies to the next session. Stage 0 enforces keep-alive + topology; the
* other stored options are shown but marked not-yet-enforced.
*/
export const DisplaySection: FC = () => {
const qc = useQueryClient();
const q = useGetDisplaySettings();
const save = useSetDisplaySettings();
// Local edit buffer, seeded once from the server and re-seeded after a successful save.
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
useEffect(() => {
if (q.data && draft === null) setDraft(q.data.settings);
}, [q.data, draft]);
const onSave = () => {
if (!draft) return;
save.mutate(
{ data: draft },
{
onSuccess: (res) => {
setDraft(res.settings);
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle>{m.host_displays()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{q.data && draft && (
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={q.data.presets}
onSave={onSave}
busy={save.isPending}
error={apiErrorMessage(save.error)}
/>
)}
</QueryState>
</CardContent>
</Card>
);
};
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
const apiErrorMessage = (err: unknown): string | undefined => {
if (err instanceof ApiError) {
const data = err.data as { error?: string } | undefined;
return data?.error ?? err.message;
}
return err ? String(err) : undefined;
};
/** The `gaming-rig` preset expands to `keep_alive: forever`, which the host rejects until the
* display-lifecycle stage — disable it rather than let the Save 400. */
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
const PRESET_LABEL: Record<string, () => string> = {
custom: m.display_preset_custom,
default: m.display_preset_default,
"gaming-rig": m.display_preset_gaming_rig,
"shared-desktop": m.display_preset_shared_desktop,
hotdesk: m.display_preset_hotdesk,
workstation: m.display_preset_workstation,
};
const TOPOLOGY_LABEL: Record<Topology, () => string> = {
auto: m.display_topology_auto,
extend: m.display_topology_extend,
primary: m.display_topology_primary,
exclusive: m.display_topology_exclusive,
};
const fmtKeepAlive = (k: KeepAlive): string => {
switch (k.mode) {
case "off":
return m.display_keep_alive_off();
case "duration":
return `${k.seconds} ${m.display_keep_alive_seconds()}`;
case "forever":
return "∞";
}
};
const DisplayForm: FC<{
draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
onSave: () => void;
busy: boolean;
error?: string;
}> = ({ draft, setDraft, presets, onSave, busy, error }) => {
const preset: Preset = draft.preset ?? "custom";
const isCustom = preset === "custom";
const keepAlive: KeepAlive = draft.keep_alive ?? { mode: "duration", seconds: 10 };
const topology: Topology = draft.topology ?? "auto";
// Preview the effective fields: from the selected preset's expansion, or the Custom fields.
const effective: EffectivePolicy | undefined = isCustom
? {
keep_alive: keepAlive,
topology,
mode_conflict: draft.mode_conflict ?? "separate",
identity: draft.identity ?? "per-client",
layout: draft.layout ?? { mode: "auto-row", positions: {} },
max_displays: draft.max_displays ?? 4,
}
: presets.find((p) => p.id === preset)?.fields;
const presetSummary = presets.find((p) => p.id === preset)?.summary;
const secondsValue = keepAlive.mode === "duration" ? keepAlive.seconds : 300;
return (
<div className="space-y-5">
{/* Preset picker */}
<div className="space-y-2">
<Label>{m.display_preset()}</Label>
<div className="flex flex-wrap gap-2">
{(["custom", "default", "gaming-rig", "shared-desktop", "hotdesk", "workstation"] as const).map(
(id) => (
<Button
key={id}
size="sm"
variant={preset === id ? "default" : "outline"}
disabled={busy || DISABLED_PRESETS.has(id)}
onClick={() => setDraft({ ...draft, preset: id as Preset })}
>
{(PRESET_LABEL[id] ?? (() => id))()}
</Button>
),
)}
</div>
{presetSummary && !isCustom && (
<p className="text-xs text-muted-foreground">{presetSummary}</p>
)}
</div>
{/* Custom fields: keep-alive + topology + max displays */}
{isCustom && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-2">
<Label>{m.display_keep_alive()}</Label>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={keepAlive.mode === "off" ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
>
{m.display_keep_alive_off()}
</Button>
<Input
type="number"
min={0}
className="w-24"
value={secondsValue}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
keep_alive: {
mode: "duration",
seconds: Math.max(0, Number(e.target.value) || 0),
},
})
}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</div>
<div className="space-y-2">
<Label>{m.display_topology()}</Label>
<div className="flex flex-wrap gap-2">
{(["auto", "extend", "primary", "exclusive"] as const).map((t) => (
<Button
key={t}
size="sm"
variant={topology === t ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, topology: t })}
>
{TOPOLOGY_LABEL[t]()}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="disp-max">{m.display_max()}</Label>
<Input
id="disp-max"
type="number"
min={1}
max={16}
className="w-24"
value={draft.max_displays ?? 4}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
</div>
</div>
)}
{/* Effective preview */}
{effective && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">{TOPOLOGY_LABEL[effective.topology]()}</Badge>
<Badge variant="outline">{effective.mode_conflict}</Badge>
<Badge variant="outline">{effective.identity}</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
</div>
)}
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
{error && (
<p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>
)}
<Button onClick={onSave} disabled={busy}>
{m.display_save()}
</Button>
</div>
);
};
+7 -1
View File
@@ -1,6 +1,7 @@
import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { DisplaySection } from "./DisplayCard";
import { GpuSection } from "./GpuCard";
import { HostView } from "./view";
@@ -10,6 +11,11 @@ export const SectionHost: FC = () => {
const compositors = useListCompositors();
return (
<HostView host={host} compositors={compositors} gpu={<GpuSection />} />
<HostView
host={host}
compositors={compositors}
gpu={<GpuSection />}
displays={<DisplaySection />}
/>
);
};
+5 -1
View File
@@ -13,7 +13,9 @@ export const HostView: FC<{
compositors: Loadable<AvailableCompositor[]>;
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
gpu?: ReactNode;
}> = ({ host, compositors, gpu }) => {
/** The virtual-display management card (self-contained container — see `DisplayCard.tsx`). */
displays?: ReactNode;
}> = ({ host, compositors, gpu, displays }) => {
const h = host.data;
return (
<Section maxWidth={false}>
@@ -81,6 +83,8 @@ export const HostView: FC<{
{gpu}
{displays}
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>