From bbd98241e43d4d044f7cf88759621ab26042dc56 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 19:44:18 +0000 Subject: [PATCH] feat(vdisplay): display-management policy surface (Stage 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/openapi.json | 372 ++++++++- crates/punktfunk-host/src/mgmt.rs | 182 +++++ crates/punktfunk-host/src/vdisplay.rs | 68 +- crates/punktfunk-host/src/vdisplay/policy.rs | 573 ++++++++++++++ .../src/vdisplay/windows/manager.rs | 42 +- design/display-management.md | 732 ++++++++++++++++++ docs-site/content/docs/configuration.md | 10 +- docs-site/content/docs/meta.json | 1 + docs-site/content/docs/virtual-displays.md | 133 ++++ web/messages/de.json | 21 + web/messages/en.json | 21 + web/src/sections/Host/DisplayCard.tsx | 269 +++++++ web/src/sections/Host/index.tsx | 8 +- web/src/sections/Host/view.tsx | 6 +- 14 files changed, 2419 insertions(+), 19 deletions(-) create mode 100644 crates/punktfunk-host/src/vdisplay/policy.rs create mode 100644 design/display-management.md create mode 100644 docs-site/content/docs/virtual-displays.md create mode 100644 web/src/sections/Host/DisplayCard.tsx diff --git a/api/openapi.json b/api/openapi.json index 96610d4..311c233 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -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/`;\n`command` → run `` 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" diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index fda1bef..d711aa0 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -156,6 +156,8 @@ fn api_router_parts() -> (Router>, 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) -> 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, + /// 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, +} + +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 { + 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, +) -> 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( diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 28f039b..70a3eeb 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -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")] diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs new file mode 100644 index 0000000..1987037 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -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 `/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, +} + +/// 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 { + 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>, +} + +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::(&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 { + self.cur.lock().unwrap().clone() + } + + /// The effective (preset-expanded) policy the console configured, or `None` when unconfigured. + pub fn configured_effective(&self) -> Option { + 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 = 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::(&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); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 953ffb2..b7ffd7d 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -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 { 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() +} diff --git a/design/display-management.md b/design/display-management.md new file mode 100644 index 0000000..b0d0540 --- /dev/null +++ b/design/display-management.md @@ -0,0 +1,732 @@ +# Virtual-display management & lifecycle policy — design + +> **Status:** PLANNED (nothing implemented). This doc designs a **policy layer on top of the +> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive +> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second +> client wants a different mode), stable display identity (so desktop environments remember +> per-client settings like scaling), and **multi-monitor** (several virtual displays forming one +> desktop, fed by one client or by several). The `VirtualDisplay` trait and the per-backend +> `create()` mechanics stay as they are; this layer decides *when* to create, *how many*, *how +> long* to keep, *what else* to do to the topology, and *under which identity*. + +Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md` +(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent, +not required). + +## 1. Goal + +Today the virtual-display behavior is hardcoded per platform and per backend: + +- A session's virtual output is created at connect and torn down (RAII) at session end — a + disconnect destroys the display, reshuffles the desktop, and (on gamescope bare-spawn) **kills + the running game**. +- "Make the streamed output the sole desktop" is an env knob on Linux + (`PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY`, default-on for the + auto-detected desktop path) and default-on on Windows (`PUNKTFUNK_NO_ISOLATE` to opt out) — + and on Linux "primary" and "disable the other outputs" are conflated into one switch. +- What happens when a second client connects is an emergent property of the platform: Linux + creates a second output (multi-view), Windows **reconfigures the shared monitor under the + live session** (join-path `reconfigure` in `vdisplay/windows/manager.rs::acquire`), GameStream + preempts. +- Only Windows gives a client a stable monitor identity (`vdisplay/windows/identity.rs`), so only + Windows reapplies per-client display config (DPI scaling) across reconnects. On KDE every + session's output is `Virtual-punktfunk` at whatever mode — scaling has to be re-set per connect + and is shared across every client. +- One session = exactly one display. A client with two physical monitors can only stream one; + a tablet can't join an existing streamed desktop *as a second monitor* on purpose (the Linux + multi-view behavior half-does it by accident, with no layout control). + +Goal: **one shared, documented configuration surface** — a small set of orthogonal options with +safe defaults and selectable presets, stored host-side, editable from the web console, applied +uniformly across the punktfunk/1 and GameStream paths and across all five backends (KWin, +gamescope, Mutter, wlroots, Windows pf-vdisplay), each backend implementing what it can and +**honestly declining** what it can't (the same honest-downgrade convention as 4:4:4/10-bit). + +## 2. What exists today (inventory) + +The asymmetry worth internalizing: **Windows already has most of the machinery; Linux has none.** + +| Mechanism | Windows (pf-vdisplay) | Linux (kwin/mutter/wlroots) | gamescope | +|---|---|---|---| +| Lifecycle owner | `VirtualDisplayManager` singleton — `Idle / Active{refs} / Lingering{until}` state machine, gen-stamped `MonitorLease` | none — session owns `VirtualOutput.keepalive`, capturer drop = teardown | managed path: debounced TV-session restore (`RESTORE_DEBOUNCE` 5 s) + warm-session reuse; spawn path: child dies with the session | +| Keep-alive after disconnect | linger, default 10 s (`PUNKTFUNK_MONITOR_LINGER_MS`) | none | managed: 5 s debounce (hardcoded) | +| Reuse on reconnect | join Active (refcount++) / adopt Lingering (with a dead-swapchain preempt for IDD) | none (always create fresh) | managed: reuses the warm session | +| Primary / exclusive | `isolate_displays_ccd` (exclusive), default on, restore on teardown | `apply_virtual_primary` = primary **and** disable others, env-gated, restore on drop; Mutter `make_virtual_primary` = sole monitor (APPLY_TEMPORARY) | n/a (own nested session) | +| Mode conflict | join-path silently reconfigures the shared monitor (last-wins) | each session gets its own output (multi-view) | managed: one session; spawn: one gamescope per client | +| Stable identity | `identity.rs` — cert-fp → id 1..=15 (EDID serial + ConnectorIndex), LRU, persisted `pf-vdisplay-identity.json` | none — KWin output always named `punktfunk`, sway `HEADLESS-N`, Mutter auto-serial | n/a | +| Multi-monitor | manager is single-monitor (driver supports 16 connectors) | N outputs happen to coexist (multi-view), no layout/group semantics | single-output nested session | + +Design consequence: the plan is **not** "build a manager" — it's (a) extract the state machine +Windows already proved into a platform-neutral, unit-testable core, (b) give Linux the ownership +split it's missing (manager owns the keepalive, session holds a lease), (c) put a typed policy +in front of both, (d) extend identity to Linux where the compositor allows it, and (e) grow the +slot model into display **groups** so multi-monitor is an arrangement of slots, not a new system. + +## 3. Architecture + +Three new pieces, layered strictly **above** the `VirtualDisplay` trait (no backend rewrite): + +``` + ┌────────────────────────────────────────────┐ + mgmt API / console │ DisplayPolicy (vdisplay/policy.rs) │ pure config: schema, + host.env compat ───▶│ presets · layout · validation · persist │ presets, env-compat + └───────────────┬────────────────────────────┘ + │ read per acquire/release (live-reload) + ┌───────────────▼────────────────────────────┐ + punktfunk/1 session │ DisplayRegistry (vdisplay/registry.rs) │ host-lifetime singleton: + GameStream session ─▶ acquire(identity, mode) → DisplayLease │ owns ManagedDisplay slots + mgmt /display/state │ release(lease) · linger timer · groups │ grouped per desktop, + └───────┬────────────────────────┬───────────┘ drives the pure Lifecycle + │ create()/drop keepalive │ reconfigure/topology/layout ops + ┌────────────▼──────────┐ ┌──────────▼───────────────┐ + │ Linux backends │ │ Windows │ + │ kwin · gamescope · │ │ VirtualDisplayManager │ + │ mutter · wlroots │ │ (existing; delegates its │ + │ (unchanged trait) │ │ state decisions upward) │ + └───────────────────────┘ └──────────────────────────┘ +``` + +- **`vdisplay/policy.rs`** — the typed config (`DisplayPolicy`), preset expansion, JSON + persistence (`/display-settings.json`, the `gpu-settings.json` pattern: sanitize on + load, atomic tmp+rename write), and the deprecated-env-knob mapping. 100 % pure and + unit-tested (the `pick_gamescope_mode` / `wiring_plan.rs` discipline). +- **`vdisplay/lifecycle.rs`** — the pure state machine: per-slot + `Idle / Active{refs} / Lingering{until} / Pinned` plus the **admission decision function** + (given: policy, requesting identity, requested mode(s), current slots → `Create | Reuse | + Reconfigure | Join{at_mode} | Steal{victims} | Reject{reason}`). No I/O, no OS types — fully + proptest/unit-testable, shared verbatim by both platforms. `Pinned` is `Lingering` with no + deadline (keep-alive **forever**), releasable only via mgmt/teardown. +- **`vdisplay/registry.rs`** — the host-lifetime singleton that owns `ManagedDisplay` slots + (the backend `VirtualOutput` **including its `keepalive`**, the identity slot, current mode, + group membership, topology-restore state) and executes the lifecycle decisions: calls + `VirtualDisplay::create`, holds keepalives past session end, runs the linger timer, applies + layout, exposes the mgmt snapshot. On Windows it wraps the existing `VirtualDisplayManager` + (which keeps its driver/CCD/preempt specifics — the IDD dead-swapchain preempt, the + WUDFHost-death preempt, `begin_idd_setup` — but reads its linger duration and join/steal + behavior from the policy instead of env/hardcode). + +### The ownership split (the one real refactor) + +Today `capture::capture_virtual_output(vout, …)` consumes the whole `VirtualOutput` — the +capturer owns the keepalive, so capturer drop tears the display down. That coupling is exactly +what makes keep-alive impossible on Linux. Split it: + +```rust +pub struct DisplayLease { /* registry handle + gen stamp; Drop = release(refcount--) */ } +pub struct CaptureSource { // what capture actually needs — Copy-ish, no ownership + pub node_id: u32, + pub remote_fd: Option, // Mutter portal daemon (dup'd per capture attach) + pub preferred_mode: Option<(u32, u32, u32)>, + #[cfg(windows)] pub win_capture: Option, +} +// registry.acquire(...) -> (DisplayLease, CaptureSource) +``` + +The `keepalive: Box` moves into `ManagedDisplay` inside the registry. The session's +pipeline holds the `DisplayLease` (mirrors the Windows `MonitorLease`, gen-stamped so a stale +lease from a preempted display is a release-no-op — the proven pattern). `build_pipeline`'s +`vd.create(mode)` call sites (`punktfunk1.rs`, `gamestream/stream.rs`, `spike.rs`) become +`registry::acquire(...)`. Every failure/retry path keeps its shape — the retry-hold lease trick +in `build_pipeline_with_retry` maps 1:1 onto a `DisplayLease`. + +**Re-capture on reuse** is per-backend (see §7): wlroots re-runs portal capture of the still- +existing output; KWin/Mutter reconnect a PipeWire consumer to the kept node (validation item); +gamescope re-discovers the nested compositor's node; Windows already re-targets. If re-capture +of a kept display fails, the registry falls back to **teardown + fresh create** (bounded, inside +the existing `build_pipeline_with_retry` budget) — keep-alive is an optimization, never a new +failure mode. + +## 4. The configuration surface + +### 4.1 Schema (`/display-settings.json`) + +```json5 +{ + "version": 1, + // Convenience: a named preset. "custom" (or absent) = the explicit fields below rule. + // When a preset IS named, the fields below are ignored (the console writes one or the other). + "preset": "custom", + + // How long a display (and, on gamescope, the nested session + game) survives after the last + // session detaches. "off" = teardown at session end. "forever" = until host stop / explicit + // release. Duration is seconds. + "keep_alive": { "mode": "duration", "seconds": 300 }, // "off" | {"duration", seconds} | "forever" + + // What the host does to the box's display topology while virtual displays are up: + // "extend" – add the virtual display(s), touch nothing else + // "primary" – make the group's primary virtual display the OS primary; physical outputs + // stay enabled + // "exclusive" – the managed virtual displays become the ONLY enabled outputs (physicals + // disabled, restored when the group's last display is torn down) + // "auto" – today's behavior: exclusive on the auto-detected desktop path & Windows, + // extend when the operator pinned a compositor/env said otherwise + "topology": "auto", + + // Admission when a client connects while another client's display/session is live and the + // requested mode differs (same-client reconnect ALWAYS reuses/reconfigures its own display): + // "separate" – give the new client its own virtual display ON THE SAME DESKTOP (bounded by + // max_displays) — this is also the "many clients as monitors" mode, see §6A + // "steal" – stop the existing session(s), tear down / reconfigure, serve the new client + // "join" – admit the new client AT THE EXISTING MODE (Welcome/serverinfo reflect the + // real mode — the honest-downgrade convention); never reconfigures under a + // live session + // "reject" – refuse the new client with a clear handshake error + "mode_conflict": "separate", + + // Stable display identity → desktop environments persist per-display config (KDE scaling): + // "shared" – one identity for everything (today's Linux behavior) + // "per-client" – one identity per paired client cert fingerprint (today's Windows); + // a multi-display client (§6B) gets one identity per (client, display #) + // "per-client-mode" – one identity per (client, WxH) — distinct scaling per resolution, + // at the cost of identity slots (Windows has 15; LRU eviction) + "identity": "per-client", + + // How the group's displays are arranged in the desktop coordinate space (§6.2): + // "auto-row" – left-to-right in acquire order, top-aligned (deterministic default); + // a §6B client's own monitor-arrangement hints override auto placement + // "manual" – per-identity-slot offsets below (console-arranged); wins over client hints + "layout": { "mode": "auto-row", "positions": { /* "": {"x": 0, "y": 0} */ } }, + + // Upper bound on simultaneously-live virtual displays (Active + Lingering + Pinned, across + // the whole group). Admission returns Reject/Steal (per mode_conflict) when full; a §6B + // AddDisplay beyond it is declined. Windows is additionally capped by the driver (see §7). + "max_displays": 4 +} +``` + +Deliberate non-options (rejected): + +- **Per-client policy overrides** — real, but v2. One host-global policy first; the schema keys + are chosen so a later `"clients": {"": {…}}` overlay is additive. +- **Idle timeout for Pinned displays** ("forever but tear down after 24 h") — `keep_alive` + already expresses it as a long duration; don't add a second axis. +- **Choosing the linger for capture-loss separately from clean disconnect** — the registry only + sees "last lease released"; the session layer already distinguishes and (see §5.1) an explicit + client **quit** bypasses keep-alive entirely. +- **Per-display FEC/bitrate policy knobs** — bitrate stays session-negotiated per stream as + today; a multi-display session's per-display bitrates are the client's ask, not host policy. + +### 4.2 Precedence & live-reload + +`display-settings.json` (console-written) **>** deprecated env knobs **>** built-in defaults — +the exact precedence convention the GPU preference set (`console preference > +PUNKTFUNK_RENDER_ADAPTER > auto`). The policy is **read at each acquire/release**, not once at +startup (it's file/registry state, not env — no `HostConfig` constraint), so a console change +applies to the next connect/disconnect without a host restart, same contract as the GPU card +("applies to the next session"). Env-knob compatibility mapping (all logged as deprecated when +they take effect): + +| Legacy knob | Maps to | +|---|---| +| `PUNKTFUNK_MONITOR_LINGER_MS` | `keep_alive = duration(ms/1000)` (Windows) | +| `PUNKTFUNK_NO_ISOLATE` | `topology = "extend"` (Windows) | +| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `topology = "exclusive"` when truthy, `"extend"` when explicitly `0` | + +The `apply_session_env` default-on write of `*_VIRTUAL_PRIMARY` for the auto-desktop path is +**replaced** by `topology = "auto"` resolving to exclusive on that path — one fewer process-env +mutation on the connect path (a small win for the env-race surface `ENV_LOCK` guards). + +### 4.3 Presets + +Presets are the documented, supported entry point; raw fields are the escape hatch. Expansion +lives in `policy.rs` and is unit-tested so docs and code can't drift. + +| Preset | keep_alive | topology | mode_conflict | identity | layout | Story | +|---|---|---|---|---|---|---| +| `default` | 10 s | auto | separate | per-client | auto-row | Today's behavior, made explicit: short linger absorbs client hiccups/reconnects, streamed output is the sole desktop on the auto path, extra clients get their own view. | +| `gaming-rig` | forever | exclusive | steal | per-client | auto-row | Dedicated headless/couch box: the game and its display survive disconnects indefinitely; whoever connects takes the box over ("the TV model"). | +| `shared-desktop` | off | extend | separate | per-client | auto-row | Streaming a desktop someone may also use physically: never blank the real monitors, never keep ghost outputs, concurrent viewers each get a view. | +| `hotdesk` | 5 min | exclusive | reject | per-client-mode | auto-row | One user at a time with fast reattach (roaming between own devices); a second user is told the box is busy; each device+resolution keeps its own scaling. | +| `workstation` | 5 min | exclusive | separate | per-client | manual | The multi-monitor daily driver: your dual-monitor client gets both displays back exactly where you arranged them (§6B), or a tablet joins as a side monitor (§6A). | + +## 5. Option semantics in detail + +### 5.1 `keep_alive` + +**What survives.** The *display* (compositor output / IddCx monitor / spawned gamescope) and its +topology state survive; the *session* (QUIC conn, capture stream, encoder, input devices, audio +plumbing) does not. Concretely per backend, "the display survives" means: + +- **kwin / mutter / wlroots**: the output stays in the layout → windows don't reshuffle, a + running game keeps rendering at the client's mode, reconnect is fast (no create/negotiate). +- **gamescope (bare spawn)**: the nested gamescope **and the game launched inside it keep + running** — this is the headline user value (Sunshine/Apollo-style detach/reattach) and the + reason `keep_alive` is worth building at all. +- **gamescope (managed)**: the policy duration replaces the hardcoded 5 s + `RESTORE_DEBOUNCE` — the warm Steam session stays up for the window; `forever` means the TV + session is never auto-restored (release via console/tray). +- **Windows**: the existing linger, plus `forever` = the new `Pinned` state. + +**Rules.** +- Input devices (uinput pads, libei/EIS contexts) stay session-scoped — a disconnect reads to + the game as "controller unplugged", which games handle. (Keeping pads alive for kept sessions + is a possible later refinement; do not build it now.) +- The **launch command runs once per display creation, never per attach** — a reconnect to a + kept gamescope must not double-launch the game. Today launch already happens once per + `build_pipeline`-successful session; the invariant moves with the create into the registry. +- An explicit client **quit** (GameStream `cancel`/quit-app; a future punktfunk/1 + `EndSession{quit}` control message — protocol growth, trailing-byte back-compat as usual) + bypasses keep-alive: the user said "stop the game", so tear down now. Plain disconnects and + connection losses honor the policy. +- Host shutdown tears everything down (RAII on exit, as today). A host crash leaves whatever + the OS reclaims — Wayland connections die with the process (compositor reclaims outputs), + spawned gamescopes die with the process group, the pf-vdisplay watchdog reaps monitors when + pings stop. No new orphan class. +- `keep_alive` + `topology=exclusive` means **physical monitors stay dark after disconnect** + until linger expiry / release. This is intended (gaming-rig) but must be loud in the docs, and + the release-now escape hatch (§8) must exist in the same release that ships `forever`. + +### 5.2 `topology` + +Splits the currently-conflated "primary" knob into three honest levels, **group-aware** (§6.1): +"exclusive" means *the managed virtual displays* are the only enabled outputs — never disable a +sibling slot; restore fires when the group's last display drops. Per-backend mapping: + +| | extend | primary | exclusive | +|---|---|---|---| +| KWin | no-op | `kscreen-doctor output.X.primary` only | primary + disable non-managed others (today's `apply_virtual_primary` with a registry-driven filter, §6.1), restore-on-teardown | +| Mutter | no-op | `ApplyMonitorsConfig` incl. physicals, virtual primary | today's sole-monitor config (`make_virtual_primary`) extended to include all group members | +| wlroots | no-op | **unsupported** (no primary concept) → log + treat as extend | `swaymsg output disable` + re-enable on teardown (new, small) | +| gamescope | n/a — the nested session *is* the whole world; all three resolve to no-op | | | +| Windows | skip isolate (today's `PUNKTFUNK_NO_ISOLATE`) | CCD primary-only variant (new, small — `set_active_mode` already exists; primary without deactivation) | today's `isolate_displays_ccd`, extended to isolate to the SET of managed targets | + +Restore stays bound to **display teardown** (keepalive drop / `teardown()`), not session end — +already true everywhere; keep-alive inherits it for free. The KWin restore-before-reclaim +ordering (re-enable others *first* so KWin never sees zero enabled outputs) is preserved. + +`auto` resolves at acquire time: exclusive on Windows and on the Linux auto-detected-desktop +path, extend under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test posture) — bit-for-bit +today's defaults, so `default` preset = no behavior change. + +### 5.3 `mode_conflict` + +Enforced at **admission**, before the Welcome / RTSP launch, in the lifecycle decision function +— so the client gets an honest answer, not a mid-build failure: + +- Applies only across **different clients** (identity ≠ identity). A same-client reconnect + always preempts its own zombie session / adopts its own kept display and reconfigures it to + the newly requested mode (today's behavior, now uniform on all platforms). +- `separate` — allocate another slot in the desktop group (Linux multi-view today, upgraded + with layout — §6A; Windows: **requires the multi-monitor manager, §6.6** — until that stage + lands, `separate` on Windows resolves to `join` with a startup + docs warning rather than + silently doing something else). +- `join` — the second client is admitted at the live display's mode. punktfunk/1: the Welcome's + `Config` carries the real mode (the client already renders what the Welcome says — the + 4:4:4/10-bit honest-downgrade pattern). GameStream: serverinfo/RTSP negotiate the live mode. + **This replaces the Windows join-path's silent last-wins `reconfigure` under a live session** + — that current behavior becomes opt-in as `steal`. +- `steal` — signal the victim sessions' stop flags (the machinery `begin_idd_setup` already + uses), wait the release grace, tear down or reconfigure, admit. Trust note: conflict policy + runs **after** the pairing gate, so on a default host only paired clients can steal; on an + `--open`/TOFU host any accepted client can — the docs call this out and recommend `reject` + for open hosts. +- `reject` — punktfunk/1: a typed handshake refusal (extend the existing error path with a + `busy` reason string carrying the live mode + client label so the client UI can say "host is + streaming 2560×1440 to "); GameStream: the 503/session-in-use answer Moonlight already + understands. + +Interaction with `--max-concurrent` (session bound) is unchanged and orthogonal: sessions and +displays are different resources; `max_displays` bounds displays, the accept-loop permit bounds +in-flight sessions. `join` deliberately lets N sessions share one display (that's today's +Windows concurrency model). + +### 5.4 `identity` — stable displays, persistent scaling (the KDE ask) + +Two halves: an **identity map** (who gets which slot) and a **per-backend identity carrier** +(how a slot becomes something the DE keys its config on). + +**Map** — generalize `vdisplay/windows/identity.rs` (it's already pure + unit-tested) into a +platform-neutral `vdisplay/identity.rs`: key = client cert fp (plus display ordinal for a §6B +multi-display client, plus WxH under `per-client-mode`), value = small stable slot id, LRU +eviction at the platform cap, persisted `/display-identity.json` (Windows migrates +`pf-vdisplay-identity.json` on first load — read old path if new absent, write new). +Anonymous/unpaired clients stay slot 0 = auto/shared. **GameStream clients get identities too** +(improvement over today): the paired GameStream client cert fingerprint feeds the same map, so a +Moonlight device also keeps its scaling — today `set_client_identity` is only wired on the +punktfunk/1 path. + +**Carriers per backend:** + +- **Windows** — shipped: slot → EDID serial + IddCx ConnectorIndex; Windows keys + `PerMonitorSettings` (DPI scaling) on exactly that. Cap 15 (ConnectorIndex < + MaxMonitorsSupported=16). `per-client-mode` and per-display ordinals work unchanged but burn + slots faster — the LRU already handles pressure; document the trade-off. +- **KWin** — the carrier is the **output name**: `stream_virtual_output(name, …)` becomes + `punktfunk-` → output `Virtual-punktfunk-`. KWin persists per-output config + (scale, transform, mode) in `kwinoutputconfig.json`, matching EDID-less outputs **by name** — + so a stable per-client name is precisely what makes KDE reapply that client's scaling. + Two validation items before relying on it (Stage 3 gate, §11): + 1. confirm KWin ≥ 6.5.6 actually persists + reapplies scale for `Virtual-*` outputs; + 2. confirm a *remembered mode* doesn't fight the freshly requested one (if KWin reapplies a + stale stored mode on output-added, our existing `set_custom_refresh`/mode apply must run + after and win — it already reads back the achieved mode, so a fight is at least visible). + Side effect worth having: distinct names also unclash concurrent sessions (today two + simultaneous KWin sessions both create `Virtual-punktfunk` and `set_custom_refresh` / + `other_enabled_outputs` match **by that shared name** — a latent multi-view bug this fixes). +- **wlroots** — no rename and no settable description via IPC; headless outputs are + `HEADLESS-N` by creation order. Identity is therefore **not reliably carriable** → declared + unsupported (`shared` behavior regardless of setting; capability matrix + docs say so). The + single-session case is de-facto stable (`HEADLESS-1`), which users can pin in sway config — + document that recipe instead of pretending. +- **Mutter** — `RecordVirtual` auto-generates the virtual monitor's serial; no public D-Bus + surface to control it → unsupported for now. Note for later: re-evaluate Mutter's + virtual-monitor D-Bus surface per GNOME release (tracked as an open item, not a promise). +- **gamescope** — n/a: the client streams a whole nested session; scaling inside it is per-game. + +**Scale as a punktfunk-side option (small, high-value adjunct):** KWin's +`stream_virtual_output` takes a `scale` argument we currently hardcode to `1.0`. Add an optional +per-client `default_scale` (console-editable next to the device list) passed at create on KWin; +on Windows scaling stays the OS's job (identity makes it persist). This gives HiDPI phones/ +tablets a correct-sized desktop on first connect, before any DE-side persistence exists. A +client-requested scale hint in the Hello (trailing-byte back-compat, like the gamepad-pref byte) +is future protocol growth — design it when a client actually wants to send it. + +## 6. Multi-monitor + +Two scenarios, deliberately separated because they differ ~10× in cost: + +- **§6A — many clients, one desktop ("second screen")**: each client device becomes one more + monitor of the same host desktop (tablet as a side monitor next to the laptop's stream). + Structurally this already half-exists on the Linux desktop compositors (`separate` gives + every client its own output on the shared desktop); what's missing is *intent*: layout + control, group-aware topology, and honest per-backend gating. **No protocol change** — it + ships on the registry work. +- **§6B — one client, many displays**: a client with two physical monitors gets two virtual + displays, streamed as two video planes, presented one-per-monitor, arranged on the host to + mirror the client's physical arrangement. Needs protocol growth, N encoder pipelines, client + presenter work, and (on Windows) the multi-monitor manager. **punktfunk/1-native only** — + GameStream/Moonlight has no multi-display vocabulary and stays single-stream. + +### 6.1 Display groups (registry concept, serves both) + +`ManagedDisplay` slots gain a **group**: the set of displays sharing one desktop/session. + +- kwin / mutter / wlroots: one group per compositor session — every acquired slot joins it + (that *is* the shared desktop). +- gamescope spawn: one group per spawned nested session. gamescope is single-output — a §6B + client asking N displays there resolves to 1, honestly (the extra `AddDisplay`s are declined). +- Windows: one group (the desktop); slots = IddCx monitors (§6.6). + +Group-aware semantics — these fix latent issues even before multi-monitor ships: + +- **`exclusive` disables only non-managed (physical/bootstrap) outputs, never group members.** + Today's KWin `apply_virtual_primary` disables "everything not named `Virtual-punktfunk`" — + under Stage-3 per-slot names, a second session's exclusive would disable the *first* session's + live output. The filter must consult the registry (the set of managed output names), not one + hardcoded name. Same shape on Windows (`isolate_displays_ccd` isolates to the managed target + *set*) and Mutter (the sole-monitor config includes all group members). +- **`primary` designates one group member** — for §6B the client marks which of its displays is + primary (its OS already knows); for §6A the first slot wins unless the console re-designates. +- **Topology restore is per-group, not per-display** — the saved pre-stream config is restored + when the group's **last** member drops, never while siblings live. (Windows `SavedConfig` and + the KWin `restore` vec move from `Monitor`/`StopGuard` into the group record.) + +### 6.2 Layout + +The `layout` policy block (§4.1) controls where group members sit in the desktop space: + +- `auto-row` (default): left-to-right in acquire order, top-aligned — what compositors mostly + do anyway, made deterministic. +- `manual`: per-identity-slot offsets, console-edited (an OS-settings-style drag mini-map is + the stretch UI; an x/y table ships first). Keyed by identity slot, so *client B's tablet + always reappears to the right of client A's monitor* — layout + identity compose. +- A §6B client sends its real monitor arrangement as per-display position hints; they override + `auto-row` (mouse crossing between streamed monitors then matches the client's physical + layout) but lose to `manual` pins. + +Backend mapping — all existing tooling, no new protocols: KWin +`kscreen-doctor output.X.position.x,y` (validate syntax the way `set_custom_refresh` did); +wlroots `swaymsg output position X Y`; Mutter logical-monitor positions in the same +`ApplyMonitorsConfig` we already build; Windows CCD source origins in the same +`SetDisplayConfig` path `isolate_displays_ccd` uses. + +**Host-side input routing.** §6A needs nothing (N clients inject into one desktop — already +true today). §6B needs the injectors to map `(display, x, y)` → desktop coordinates using the +group layout: per-backend work items — libei absolute positioning is per-region, the wlr +virtual-pointer protocol binds to an output, Windows `SendInput` absolute is desktop-normalized +(pure math off the group layout). Wire change in §6.3. + +Two realities to document, not engineer around: **cursor rendering is already correct** (every +backend embeds the cursor per-output — KWin `POINTER_EMBEDDED`, the IDD's per-monitor +composition — so it appears only on the stream it's on and "crosses" between monitors +naturally), and **a §6A desktop has one cursor shared by all member clients** — exactly right +for the one-user-two-devices case (touch the tablet, the cursor jumps there), chaotic for two +people; genuinely independent users want gamescope multi-user +(`design/gamescope-multiuser.md`), not groups. + +### 6.3 Protocol growth for §6B (punktfunk/1 only) + +Principle: **a display is one data-plane instance.** Don't touch the hardened core packet +format — N displays = N × (encoder + send thread + core `Session` over its own UDP flow), one +shared QUIC control connection, one set of session-scoped side planes (audio, mic, rumble, +input). And **don't grow the Hello**: the handshake's back-compat idiom is single trailing +bytes — a variable-length display list doesn't fit it, and it doesn't need to, because the +control stream stays open after `Start` (Reconfigure/ClockProbe already ride it). + +- **Capability**: client advertises `VIDEO_CAP_MULTI_DISPLAY` (`video_caps` bit `0x10`); the + Welcome echoes the host's per-session display budget as one trailing byte (`max_displays` + remaining, `0`/absent = single-display host — old hosts are automatically honest). +- **Negotiation**: the Hello/Welcome pair is untouched and establishes **display 0** exactly as + today (an old host serves a multi-monitor-capable client's primary display with zero special + cases). Extra displays negotiate post-`Start` on the control stream: + `AddDisplay { mode, position_hint, primary: bool } → DisplayAdded { index, config /* the same + honest per-display Config shape the Welcome carries: mode, bit depth, chroma, codec */ }` or + `DisplayDeclined { reason }`. `RemoveDisplay { index }` and a per-display `Reconfigure` + (index as a trailing byte on the existing message) complete the set — **client monitor + hotplug maps 1:1 onto Add/Remove mid-session.** +- **Data plane**: `DisplayAdded` carries the flow binding (host UDP port / flow token) for that + display's own core `Session`. Per-flow crypto derives the AES-GCM nonce salts per + (direction, display index) — no salt reuse across flows; FEC domains are independent per flow + (loss on one display can't stall another) — this is why "one Session per display" beats + muxing display ids into the core packet format. +- **Side planes**: pointer/touch events gain a display-index byte (same trailing-byte pattern + as the gamepad pref; absent = display 0); 0xCF host-timing and 0xCE HDR-metadata datagrams + gain the index the same way (a client mixing an HDR laptop panel + SDR external monitor gets + per-display grades). Audio/mic/rumble/gamepad stay session-scoped, untouched. +- **Per-display honesty**: each display negotiates bit depth/chroma/codec independently through + the same resolve functions — a host that can afford HEVC Main10 on one head and only 4:2:0 on + the second says so in each `DisplayAdded.config`. +- **Stats**: the stats-unification vocabulary (four measurement points, p50/p95 windows) gains + a display dimension — per-display series, HUD shows the focused display's equation + (`design/stats-unification.md` gets a §6B addendum; don't invent client-local stats). +- **C ABI / connector**: `punktfunk_add_display` / per-display `next_au` routing (an index out + param on the existing call keeps the ABI additive), so PunktfunkKit/JNI stay on the shared + connector. + +### 6.4 Encoder & resource budget + +N displays = N encode pipelines. NVENC consumer session caps — and the existing auto 2-way +**split-encode** above ~1 Gpix/s consuming *two* NVENC sessions for one stream — mean admission +must budget: `DisplayAdded` is granted only if the encoder backend confirms capacity (extend the +existing NVENC session accounting + the AMF/QSV probes with a `can_open_another()` check), and +**split-encode is disabled for multi-display sessions** (displays win over split; a 5K@240 +single head is not the multi-monitor use case). `max_displays` bounds the group. Same idle-cost +note as keep-alive: every added display composites + encodes at full rate. Bandwidth is +per-display additive (two 4K heads ≈ 2× the bitrate): the per-host speed test's recommendation +should be read **per session** and split across that session's displays — the client divides +its ask, the host doesn't second-guess it (per-display bitrate is deliberately not host policy, +§4.1). + +### 6.5 Client staging for §6B + +- **Linux GTK + Windows clients first** — natural multi-window presenters: one + window/fullscreen surface per display on the matching physical monitor, the existing capture + state machine extended to span them (pointer crossing between our fullscreen windows must not + release capture). +- **macOS second** (multi-NSWindow across Screens; Spaces/fullscreen interplay is the risk). +- **Android/iOS/tvOS: never advertise the capability** — single-display presenters. A phone or + tablet still participates in multi-monitor via §6A (it *is* a second monitor), which needs + nothing from those clients. + +### 6.6 Windows multi-monitor manager + +Previously an explicit non-goal; now a designed **final stage** — the single-monitor manager +keeps working unchanged until it lands: + +- **Manager**: the singleton's `MgrState` becomes a map keyed by connector id; `lifecycle.rs` + is already written per-slot, so the Windows manager's delegation doesn't change shape. The + IDD reconnect preempts (dead-swapchain, WUDFHost-death) become per-slot. +- **Driver**: pf-vdisplay already ADDs by connector id 1..=15 (the identity map's bound). The + sealed frame channel (`IOCTL_SET_FRAME_CHANNEL`) must become **per-monitor** — channel + messages carry the monitor id, reusing the multi-pad `pad_index` pattern (driver proto v3; + `design/idd-push-security.md` addendum: same unnamed-object + handle-dup broker per ring). + Driver work + CI + on-glass validation is exactly why this stage is last. +- **Capture/encode**: one IDD-push capturer per monitor ring; budget per §6.4. +- **CCD**: isolate/primary/layout already group-aware from §6.1/6.2. + +## 7. Per-backend capability matrix + +What each backend supports; unsupported cells resolve to the stated fallback and are surfaced in +`GET /api/v1/display/state` per display (`"capabilities": [...]`) so the console can grey options +out per-host instead of lying: + +| Capability | KWin | gamescope spawn | gamescope managed | gamescope attach | Mutter | wlroots | Windows | +|---|---|---|---|---|---|---|---| +| keep-alive (linger/forever) | ✅ hold the vout thread; re-attach PipeWire consumer to the kept node — **validate** | ✅ nested session + game survive; re-discover node | ✅ policy replaces the 5 s debounce | — (never owned it) | ✅ hold the D-Bus session; consumer re-attach — **validate** | ✅ output persists; fresh portal capture per attach (cleanest) | ✅ shipped; add `Pinned` | +| reconfigure kept display to a new mode | ✅ `set_custom_refresh` + kscreen mode | ✅ SIGKILL+respawn is the honest "reconfigure" (game restarts — docs say so) or decline → recreate | ✅ existing managed-mode set | — | ⚠ node is sized by negotiation; renegotiation unproven — fallback recreate | ✅ `output mode --custom` | ✅ `reconfigure()` shipped | +| topology: primary | ✅ | n/a | n/a | n/a | ✅ | ❌ → extend | ✅ (new, small) | +| topology: exclusive | ✅ shipped (filter → group-aware) | n/a | n/a | n/a | ✅ shipped (→ group-aware) | ✅ (new, small) | ✅ shipped (→ group-aware) | +| mode_conflict: separate / §6A group | ✅ multi-output | ✅ one gamescope per client (independent sessions, no shared desktop) | ❌ single session → steal/join/reject only | — | ✅ assumed — **validate ≥2 RecordVirtual monitors** | ✅ HEADLESS-N | ⏳ §6.6 (until then → join + warning) | +| §6B multi-display for one client | ✅ N outputs + layout | ❌ single-output (extra displays declined) | ❌ | — | ⚠ gated on the ≥2-monitor validation | ✅ | ⏳ §6.6 | +| layout (position control) | ✅ kscreen position | n/a | n/a | n/a | ✅ ApplyMonitorsConfig | ✅ `output position` | ✅ CCD origins | +| stable identity | ✅ output name per slot | n/a | n/a | n/a | ❌ (API gives no serial control) | ❌ (no name control) | ✅ shipped | + +The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the +registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity, +conflict = join-only. That's just codifying reality. + +## 8. Management API, web console, tray + +Endpoints (bearer-only, like `/gpus`; documented in `mgmt.rs`'s OpenAPI → regenerate +`api/openapi.json`): + +- `GET /api/v1/display/settings` → `{ settings, preset_expansions, capabilities }` — the stored + policy plus what this host's live backend can actually do (so the console renders accurate + controls). +- `PUT /api/v1/display/settings` — validate (unknown fields rejected, ranges clamped like the + GPU PUT), persist atomically, log. Applies from the next acquire/release. +- `GET /api/v1/display/state` → live slots: + ```json + { "displays": [ { "slot": 3, "backend": "kwin", "output": "Virtual-punktfunk-3", + "mode": "2560x1440@120", "state": "lingering", "expires_in_s": 240, + "client": "a1b2c3…(label)", "display_index": 0, "sessions": 0, + "group": 1, "position": {"x": 0, "y": 0}, "topology": "exclusive" } ] } + ``` +- `POST /api/v1/display/release` `{ "slot": 3 }` or `{}` (all) — immediately tear down + Lingering/Pinned displays. **Refuses Active** (stopping a live session is session management, + not display management — don't blur it). +- `PUT /api/v1/display/layout` `{ "positions": { "": {"x":…, "y":…} } }` — the manual + arrangement (applies live to affected groups; persisted into the policy's layout block). + +Web console (Host page, next to the GPU card): a **Virtual displays** card — preset selector +(radio + one-line story each, `custom` unlocking the advanced fields), the live display list from +`/state` with per-row "Release" buttons and a linger countdown, the arrangement editor (x/y +table first, drag mini-map stretch), capability-aware disabled states. The loopback +`local/summary` gains a `displays_live` count (counts only — the established no-secrets rule) so +the **tray** tooltip can show "1 display kept alive" and offer a release-all action through the +same elevation path as start/stop (Windows) / `systemctl --user` (Linux) — tray work is a +stretch stage, not core. + +## 9. Enforcement points (exact code paths) + +1. **punktfunk/1 handshake** (`punktfunk1.rs`, where the Hello is resolved into the Welcome): + call `registry::admit(identity, requested_mode)` → on `Reject` answer the typed refusal; on + `Join` the Welcome's `Config` carries the live mode; on `Steal` signal victims + wait release + (bounded) before proceeding. This runs **before** `SessionContext` is built. +2. **`virtual_stream` / `build_pipeline`** (`punktfunk1.rs:3511`, `build_pipeline_with_retry`): + `vd.create(mode)` → `registry::acquire(...) -> (DisplayLease, CaptureSource)`; the retry-hold + lease keeps its exact semantics. The mid-stream **Reconfigure**, **session-switch**, and + **capture-loss rebuild** paths re-acquire through the registry so a compositor switch + correctly releases the old backend's slot and the new mode updates the slot's record. +3. **Control stream, post-Start** (§6B): `AddDisplay`/`RemoveDisplay` handlers spawn/stop a + per-display pipeline (its own `registry::acquire`, encoder, send thread, UDP flow) inside the + same `SessionContext` lifetime; `--max-concurrent` counts sessions, not displays. +4. **GameStream** (`gamestream/stream.rs::open_gs_virtual_source`): same acquire; identity from + the paired client cert fp (new); quit-app → `release(quit=true)` which bypasses keep-alive. +5. **Session end**: capturer drop (releases the PipeWire consumer / ring) then `DisplayLease` + drop → lifecycle decides Linger/Pinned/teardown. On Linux the keepalive no longer rides the + capturer (§3 ownership split). +6. **`serve` startup/shutdown**: registry constructed once (like `start_restore_worker`), all + slots torn down on graceful exit. + +## 10. Documentation plan + +A dedicated docs-site page **`docs-site/content/docs/virtual-displays.md`** (+ `meta.json` +entry), cross-linked from `configuration.md`, `host-cli.md`, `steamos-host.md`, and +`troubleshooting.md`. Structure — written for the operator, presets first: + +1. **What punktfunk does with displays** — 5 lines: per-client-sized virtual output, created on + connect, what "keep alive"/"exclusive" mean physically. +2. **Pick a preset** — the §4.3 table verbatim, each with a one-paragraph story and the JSON it + expands to ("copy this into display-settings.json, or click it in the console"). +3. **Options reference** — one subsection per option: values, default, per-backend support + badge row, and a concrete example scenario each ("You stream from your phone at 1080p and + your TV at 4K120: with `identity: per-client` KDE remembers 150 % scaling for the phone and + 100 % for the TV"). +4. **Multi-monitor** — the two scenarios in user language: *"use your tablet as a second + monitor"* (§6A: connect a second device, arrange it in the console) and *"stream your + dual-monitor setup"* (§6B: which clients support it, what the host does with the layout), + plus the support matrix and the GameStream single-stream note. +5. **Persistent scaling (KDE/Windows)** — the user-visible recipe: connect once, set scaling in + System Settings / Windows Settings while streaming, done — punktfunk's stable identity makes + the DE reapply it. Honest support table (KWin ✅ / Windows ✅ / GNOME ❌ why / Sway recipe). +6. **Troubleshooting** — "my physical monitors stayed off" → release button/endpoint + the + keep_alive×exclusive explanation; "second client gets the wrong resolution" → `join` + semantics; "game restarted on reconnect" → gamescope reconfigure caveat; "second display + declined" → encoder budget (§6.4); KWin/gamescope version floors. +7. **Legacy env knobs** — the §4.2 mapping table, marked deprecated. + +Also update: `README.md` status row, `CLAUDE.md` (status + invariant below), `host.env.example` +(point at the JSON/console, list deprecated knobs), and the OpenAPI snapshot. + +**New design invariant for CLAUDE.md** (once shipped): *Display lifecycle is owned by the +registry, policy-driven; sessions hold leases, never the keepalive. New backends implement +`VirtualDisplay` + declare capabilities; they never grow their own lifecycle/env knobs. A +display is one data-plane instance — multi-display never muxes into the core packet format.* + +## 11. Staged implementation + +Each stage lands green (`cargo test/clippy/fmt`, OpenAPI drift check) and is independently +shippable; on-glass validation notes inline. **Heads-up for this box:** the dev VM currently has +no GPU passthrough (RTX 5070 Ti detached at the Proxmox level, 2026-07-01) — KWin-path live +validation needs the GPU back or one of the LAN hosts (.248 GNOME / .48 Fedora KDE). + +- **Stage 0 — policy + plumbing-lite.** `policy.rs` (schema/presets/persist/env-compat, fully + unit-tested), mgmt GET/PUT `/display/settings`, console card (settings only), docs page + skeleton with the presets/options tables. Behavior deltas limited to what existing knobs can + express: Windows linger reads the policy; Linux topology auto/extend/exclusive routes through + the existing primary code. *No lifecycle change yet — zero-risk adoption of the surface.* +- **Stage 1 — lifecycle core + Linux keep-alive (easy backends).** `lifecycle.rs` pure machine + (+proptests: no lost teardowns, no double-frees across arbitrary acquire/release/expiry + interleavings), `registry.rs`, the ownership split (`DisplayLease`/`CaptureSource` — the one + cross-cutting refactor, touches `capture_virtual_output` signatures on both OSes), keep-alive + live for **wlroots** and **gamescope-spawn** (the two backends where reuse is structurally + trivial), `/display/state` + `/display/release`, console live-list. Windows manager delegates + linger/pinned decisions to `lifecycle.rs` (its driver specifics untouched). + *Validate:* sway on this box (headless), gamescope spawn: connect → disconnect → verify + vkcube/game still runs → reconnect → same session, no relaunch. +- **Stage 2 — KWin/Mutter keep-alive + topology decoupling.** Kept-node PipeWire re-attach on + KWin and Mutter (each behind its validation; fallback recreate), `primary` (without disable) + on KWin/Mutter/Windows, `exclusive` on wlroots, restore paths regression-tested. + *Validate:* headless KDE session (the `run-headless-kde.sh` rig), GNOME box .248. +- **Stage 3 — identity.** Platform-neutral identity map + migration, per-slot KWin output + naming (+ the concurrent-session name-clash fix riding along), GameStream identity wiring, + optional `per-client-mode` keying, per-client `default_scale` on KWin. + *Validate on KDE:* connect client A → set 150 % scaling → disconnect → reconnect → scaling + reapplied; client B unaffected; `kwinoutputconfig.json` inspected for the named entries. +- **Stage 4 — mode-conflict admission.** Decision function wired into both handshakes, the + typed punktfunk/1 `busy` refusal, GameStream 503 path, the Windows silent-reconfigure → + `join`-default change (call it out in release notes — it's a behavior fix), `steal` victim + signaling reusing the stop-flag plumbing. + *Validate:* two probe clients loopback (`--mode` differing) under each policy value. +- **Stage 5 — §6A multi-client monitors.** Display groups, group-aware exclusive/primary/ + restore (incl. the name-filter fix), layout auto-row + manual, `/display/layout`, console + arrangement table. Cheap: rides Stages 1–3 infrastructure, no protocol change. + *Validate:* two clients (probe + GTK) on the headless KDE box forming a 2-output desktop; + drag a window across; disconnect one → its slot lingers per policy, sibling unaffected, + restore only after both drop. +- **Stage 6 — §6B protocol + Linux host + GTK client.** `VIDEO_CAP_MULTI_DISPLAY`, control- + stream Add/Remove/DisplayAdded, per-flow nonce-salt derivation, per-display pipelines on + KWin/wlroots, input display-index routing, C ABI additions, GTK client multi-window + presenter, stats display dimension. + *Validate:* loopback probe requesting 2 displays → two decodable .h265 outs + per-display + 0xCF; then a real dual-monitor Linux client against the KDE box. +- **Stage 7 — Windows multi-monitor** (§6.6: driver proto v3 per-monitor sealed rings, manager + slot map, Windows client multi-window, `separate` un-gated on Windows) — gated on driver CI + + on-glass, deliberately last. +- **Stage 8 — polish.** Docs page finalized with real console screenshots, tray count/release + (stretch), README/CLAUDE.md/host.env.example updates, `local/summary` count, macOS §6B + presenter (its own mini-stage when scheduled). + +## 12. Risks & open questions + +- **PipeWire node reuse after consumer detach (KWin/Mutter)** — the load-bearing unknown for + Stage 2. If a kept node won't renegotiate for a fresh consumer, keep-alive on those backends + degrades to "topology-stable but recreate-on-reconnect" (still valuable: no desktop reshuffle + when *paired with identity naming*). The fallback is designed in, so the stage can't strand. +- **KWin persistence of `Virtual-*` output config** — if KWin declines to persist virtual + outputs, per-client scaling on KDE needs punktfunk-side scale storage instead (the + `default_scale` adjunct already gives us the mechanism); identity naming stays worthwhile for + the name-clash fix alone. +- **KWin stored-mode vs requested-mode fights** under identity naming (§5.4) — mitigated by + our post-create mode apply + read-back; watch for it in Stage 3 validation. +- **Compositor ceilings on simultaneous virtual outputs** — load-bearing for §6A/§6B: probe + KWin's virtual-output count and Mutter's `RecordVirtual` count (≥2 monitors) empirically in + Stage 2/5; `max_displays` default 4 keeps us under any realistic ceiling. +- **Encoder session exhaustion** (§6.4) — NVENC caps × split-encode × concurrent sessions must + be budgeted in one place (the admission check), or a second display can silently break an + unrelated session's encode. Split-encode is disabled for multi-display sessions by design. +- **Per-display input mapping** — each Linux injector (libei, wlr, gamescope EIS) binds + absolute coordinates differently; the §6B display-index routing is per-injector work with + per-backend validation, not one generic patch. +- **Client-side multi-window fullscreen juggling** (§6.5) — per-monitor DPI on Windows, Spaces + on macOS, pointer capture across our own windows; the reason clients stage GTK/Windows first. +- **Idle kept displays burn resources** — a kept gamescope keeps the game rendering (GPU) at + full rate; a kept KWin output keeps compositing; every §6B display encodes at full rate. + Document; a later refinement could drop a kept session's refresh, out of scope here. +- **Security posture** — keep-alive keeps a user session composited/running unattended; + nothing is unlocked that wasn't, and admission still rides pairing. `steal` on `--open` + hosts is the one sharp edge → docs recommend `reject` there (§5.3). The mgmt endpoints are + bearer-only; `local/summary` exposes counts only. §6B's extra UDP flows reuse the hardened + core `Session` unchanged (per-flow salts derived, never reused) — no new crypto surface. +- **Mutter identity** — blocked on GNOME API surface; re-check per GNOME release. diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 657a3bb..b2e4649 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -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. | diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index e8374aa..7deedcf 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -24,6 +24,7 @@ "pairing", "---Configuration---", "configuration", + "virtual-displays", "host-cli", "---Troubleshooting---", "troubleshooting", diff --git a/docs-site/content/docs/virtual-displays.md b/docs-site/content/docs/virtual-displays.md new file mode 100644 index 0000000..5d708f9 --- /dev/null +++ b/docs-site/content/docs/virtual-displays.md @@ -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). diff --git a/web/messages/de.json b/web/messages/de.json index f9e2087..2671942 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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", diff --git a/web/messages/en.json b/web/messages/en.json index bc00fe1..4bc57c6 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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", diff --git a/web/src/sections/Host/DisplayCard.tsx b/web/src/sections/Host/DisplayCard.tsx new file mode 100644 index 0000000..65f258c --- /dev/null +++ b/web/src/sections/Host/DisplayCard.tsx @@ -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(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 ( + + + {m.host_displays()} + + +

{m.host_displays_help()}

+ + {q.data && draft && ( + + )} + +
+
+ ); +}; + +/** 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 = new Set(["gaming-rig"]); + +const PRESET_LABEL: Record 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 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 ( +
+ {/* Preset picker */} +
+ +
+ {(["custom", "default", "gaming-rig", "shared-desktop", "hotdesk", "workstation"] as const).map( + (id) => ( + + ), + )} +
+ {presetSummary && !isCustom && ( +

{presetSummary}

+ )} +
+ + {/* Custom fields: keep-alive + topology + max displays */} + {isCustom && ( +
+
+ +
+ + + setDraft({ + ...draft, + keep_alive: { + mode: "duration", + seconds: Math.max(0, Number(e.target.value) || 0), + }, + }) + } + /> + + {m.display_keep_alive_seconds()} + +
+
+ +
+ +
+ {(["auto", "extend", "primary", "exclusive"] as const).map((t) => ( + + ))} +
+
+ +
+ + + setDraft({ + ...draft, + max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> +
+
+ )} + + {/* Effective preview */} + {effective && ( +
+ {m.display_effective()}: + {fmtKeepAlive(effective.keep_alive)} + {TOPOLOGY_LABEL[effective.topology]()} + {effective.mode_conflict} + {effective.identity} + {`${effective.max_displays}×`} +
+ )} + +

{m.display_pending_note()}

+ + {error && ( +

{error}

+ )} + + +
+ ); +}; diff --git a/web/src/sections/Host/index.tsx b/web/src/sections/Host/index.tsx index 81c0d27..bfd2e7a 100644 --- a/web/src/sections/Host/index.tsx +++ b/web/src/sections/Host/index.tsx @@ -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 ( - } /> + } + displays={} + /> ); }; diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index 487ce41..0f54545 100644 --- a/web/src/sections/Host/view.tsx +++ b/web/src/sections/Host/view.tsx @@ -13,7 +13,9 @@ export const HostView: FC<{ compositors: Loadable; /** 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 (
@@ -81,6 +83,8 @@ export const HostView: FC<{ {gpu} + {displays} + {m.host_compositors()}