From bbd98241e43d4d044f7cf88759621ab26042dc56 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 19:44:18 +0000 Subject: [PATCH 01/40] 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()} From 87f0ce7997aa7ec3598f814537bff44a98ab0e62 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 20:32:03 +0000 Subject: [PATCH 02/40] feat(vdisplay): lifecycle state machine + display state/release API (Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of design/display-management.md — the lifecycle core + the display management surface: - vdisplay/lifecycle.rs: pure per-slot state machine (Idle/Active{refs}/ Lingering{until}/Pinned) with acquire/release/expiry/force-release transitions. No I/O, no OS types — the platform-neutral distillation of the Windows manager's model. Unit + a 200k-iteration seeded property walk (no leaks / double-frees / refcount underflow across arbitrary interleavings). - vdisplay/registry.rs: neutral snapshot/release facade over the per-OS lifecycle owners. Windows reads/controls the VirtualDisplayManager; Linux keep-alive (a per-session pool) lands in a following increment (needs GPU-box validation). - windows/manager.rs: additive snapshot() + force_release() (no behavior change to the on-glass-validated path). - mgmt: GET /api/v1/display/state (live/kept displays) + POST /api/v1/display/release (tear down lingering/pinned now; refuses active). OpenAPI regenerated. - web console: Virtual displays card gains a live-display list (polled) with per-row + release-all buttons and a linger countdown. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/openapi.json | 171 +++++++++ crates/punktfunk-host/src/mgmt.rs | 105 +++++- crates/punktfunk-host/src/vdisplay.rs | 10 + .../punktfunk-host/src/vdisplay/lifecycle.rs | 338 ++++++++++++++++++ .../punktfunk-host/src/vdisplay/registry.rs | 80 +++++ .../src/vdisplay/windows/manager.rs | 75 ++++ web/messages/de.json | 9 + web/messages/en.json | 9 + web/src/sections/Host/DisplayCard.tsx | 93 +++++ 9 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 crates/punktfunk-host/src/vdisplay/lifecycle.rs create mode 100644 crates/punktfunk-host/src/vdisplay/registry.rs diff --git a/api/openapi.json b/api/openapi.json index 311c233..f41f8a9 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -138,6 +138,48 @@ } } }, + "/api/v1/display/release": { + "post": { + "tags": [ + "display" + ], + "summary": "Release kept virtual displays", + "description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).", + "operationId": "releaseDisplay", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseDisplayRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The number of kept displays released", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseDisplayResult" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/display/settings": { "get": { "tags": [ @@ -230,6 +272,38 @@ } } }, + "/api/v1/display/state": { + "get": { + "tags": [ + "display" + ], + "summary": "Live virtual displays", + "description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.", + "operationId": "getDisplayState", + "responses": { + "200": { + "description": "The live/kept virtual displays", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplayStateResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/gpus": { "get": { "tags": [ @@ -1693,6 +1767,59 @@ "av1" ] }, + "ApiDisplayInfo": { + "type": "object", + "description": "One live or kept virtual display.", + "required": [ + "slot", + "backend", + "mode", + "state", + "sessions" + ], + "properties": { + "backend": { + "type": "string", + "description": "Backend name (`pf-vdisplay`, `kwin`, …)." + }, + "client": { + "type": [ + "string", + "null" + ], + "description": "Short client label, when the owner tracks it." + }, + "expires_in_ms": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Milliseconds until a lingering display is torn down (absent when active/pinned).", + "minimum": 0 + }, + "mode": { + "type": "string", + "description": "`WIDTHxHEIGHT@HZ`." + }, + "sessions": { + "type": "integer", + "format": "int32", + "description": "Live sessions holding the display.", + "minimum": 0 + }, + "slot": { + "type": "integer", + "format": "int64", + "description": "Stable-enough id for the `/display/release` `slot` argument.", + "minimum": 0 + }, + "state": { + "type": "string", + "description": "`active` | `lingering` | `pinned`." + } + } + }, "ApiError": { "type": "object", "description": "Error envelope for every non-2xx response.", @@ -2076,6 +2203,21 @@ } } }, + "DisplayStateResponse": { + "type": "object", + "description": "The host's managed virtual displays right now.", + "required": [ + "displays" + ], + "properties": { + "displays": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiDisplayInfo" + } + } + } + }, "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`].", @@ -2795,6 +2937,35 @@ } } }, + "ReleaseDisplayRequest": { + "type": "object", + "description": "Request body for `releaseDisplay`.", + "properties": { + "slot": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Slot to release (see `state`); omit to release **all** kept displays.", + "minimum": 0 + } + } + }, + "ReleaseDisplayResult": { + "type": "object", + "description": "Result of a `/display/release`.", + "required": [ + "released" + ], + "properties": { + "released": { + "type": "integer", + "description": "Number of kept displays torn down.", + "minimum": 0 + } + } + }, "RuntimeStatus": { "type": "object", "description": "Live host status (changes as clients launch/end sessions).", diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index d711aa0..5c7fde1 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -158,6 +158,8 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(set_gpu_preference)) .routes(routes!(get_display_settings)) .routes(routes!(set_display_settings)) + .routes(routes!(get_display_state)) + .routes(routes!(release_display)) .routes(routes!(get_status)) .routes(routes!(get_local_summary)) .routes(routes!(list_paired_clients)) @@ -1095,6 +1097,104 @@ async fn set_display_settings( Json(display_settings_state()).into_response() } +/// One live or kept virtual display. +#[derive(Serialize, ToSchema)] +struct ApiDisplayInfo { + /// Stable-enough id for the `/display/release` `slot` argument. + slot: u64, + /// Backend name (`pf-vdisplay`, `kwin`, …). + backend: String, + /// `WIDTHxHEIGHT@HZ`. + mode: String, + /// `active` | `lingering` | `pinned`. + state: String, + /// Milliseconds until a lingering display is torn down (absent when active/pinned). + expires_in_ms: Option, + /// Live sessions holding the display. + sessions: u32, + /// Short client label, when the owner tracks it. + client: Option, +} + +/// The host's managed virtual displays right now. +#[derive(Serialize, ToSchema)] +struct DisplayStateResponse { + displays: Vec, +} + +/// Request body for `releaseDisplay`. +#[derive(Deserialize, ToSchema)] +struct ReleaseDisplayRequest { + /// Slot to release (see `state`); omit to release **all** kept displays. + #[serde(default)] + slot: Option, +} + +/// Result of a `/display/release`. +#[derive(Serialize, ToSchema)] +struct ReleaseDisplayResult { + /// Number of kept displays torn down. + released: usize, +} + +/// Live virtual displays +/// +/// The host's managed virtual displays right now — active (streaming), lingering (kept after +/// disconnect, counting down to teardown), or pinned (kept indefinitely). See +/// `design/display-management.md`. +#[utoipa::path( + get, + path = "/display/state", + tag = "display", + operation_id = "getDisplayState", + responses( + (status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn get_display_state() -> Json { + let snap = crate::vdisplay::registry::snapshot(); + Json(DisplayStateResponse { + displays: snap + .displays + .into_iter() + .map(|d| ApiDisplayInfo { + slot: d.slot, + backend: d.backend, + mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2), + state: d.state, + expires_in_ms: d.expires_in_ms, + sessions: d.sessions, + client: d.client, + }) + .collect(), + }) +} + +/// Release kept virtual displays +/// +/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back +/// without waiting out the linger. `slot` releases one; omit it to release all kept displays. +/// Active (streaming) displays are never torn down here (that is session control). +#[utoipa::path( + post, + path = "/display/release", + tag = "display", + operation_id = "releaseDisplay", + request_body = ReleaseDisplayRequest, + responses( + (status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn release_display( + ApiJson(req): ApiJson, +) -> Json { + let released = crate::vdisplay::registry::release(req.slot); + tracing::info!(slot = ?req.slot, released, "management API: display release"); + Json(ReleaseDisplayResult { released }) +} + /// Live host status #[utoipa::path( get, @@ -2650,7 +2750,10 @@ mod tests { let (status, body) = send(&app, put).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!( - body["error"].as_str().unwrap_or_default().contains("forever"), + body["error"] + .as_str() + .unwrap_or_default() + .contains("forever"), "the rejection names the unsupported option" ); } diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 70a3eeb..2a186e0 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -752,6 +752,16 @@ pub fn start_restore_worker() -> std::sync::Arc<()> { #[path = "vdisplay/policy.rs"] pub(crate) mod policy; +// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and +// property-tested; the registry executes the side effects its transitions dictate. +#[path = "vdisplay/lifecycle.rs"] +pub(crate) mod lifecycle; + +// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool +// later), for the management API's /display/state + /display/release. +#[path = "vdisplay/registry.rs"] +pub(crate) mod registry; + /// 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 diff --git a/crates/punktfunk-host/src/vdisplay/lifecycle.rs b/crates/punktfunk-host/src/vdisplay/lifecycle.rs new file mode 100644 index 0000000..d0d2f06 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/lifecycle.rs @@ -0,0 +1,338 @@ +//! Pure per-display **lifecycle state machine** (design: `design/display-management.md` §3). +//! +//! One virtual display's earned refcount + linger + pin state, with **no I/O and no OS-specific +//! types** — the registry ([`super::registry`]) executes the side effects (backend create / +//! teardown / linger timer) that this machine's transitions dictate. Extracted so the lifecycle +//! logic is unit- and property-testable in isolation, and so the Linux registry and (later) the +//! Windows manager share one audited machine instead of each re-deriving refcount+linger by hand. +//! +//! It is the platform-neutral distillation of the model the Windows `VirtualDisplayManager` already +//! runs on glass: `Idle → Active{refs} → Lingering{until} → Idle`, plus a `Pinned` state for +//! keep-alive-forever. The registry pairs one [`State`] with the owned backend resource; the machine +//! only tracks the discriminant + refcount + deadline and reports what to do. + +use std::time::Instant; + +use super::policy::Linger; + +/// The lifecycle state of one virtual-display slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum State { + /// No display exists. + #[default] + Idle, + /// A display exists with `refs` live sessions holding it. + Active { refs: u32 }, + /// The last session left; the display is kept until `until`, then torn down. + Lingering { until: Instant }, + /// The last session left; the display is kept indefinitely (keep-alive forever), until an + /// explicit release. + Pinned, +} + +/// What acquiring a slot means for the backend. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Acquire { + /// The slot was empty — the backend must CREATE a fresh display. + Create, + /// The slot was already Active — another session JOINS the live display (refcount++). + Join, + /// The slot was kept alive (Lingering/Pinned) — REUSE the existing display (re-attach capture). + Reuse, +} + +/// What releasing a hold on a slot means for the backend. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Release { + /// Another session still holds the display — nothing to do. + Decref, + /// The last session left; keep the display until its deadline ([`State::Lingering`]), then tear down. + Linger, + /// The last session left; keep the display indefinitely ([`State::Pinned`]). + Pin, + /// The last session left and keep-alive is off — tear the display down now. + Teardown, + /// A release with no live hold (stale/duplicate) — no-op. + Noop, +} + +impl State { + /// True while a backend display resource exists (Active/Lingering/Pinned) — the registry holds + /// the keepalive in exactly these states, and `Idle` means it has been dropped. + pub fn has_display(self) -> bool { + !matches!(self, State::Idle) + } + + /// Number of live sessions holding the display (0 unless Active). + pub fn refs(self) -> u32 { + match self { + State::Active { refs } => refs, + _ => 0, + } + } + + /// A session acquires the slot. Transitions the state and reports whether the backend must + /// create a fresh display, join the live one, or reuse the kept one. + pub fn acquire(&mut self) -> Acquire { + match *self { + State::Idle => { + *self = State::Active { refs: 1 }; + Acquire::Create + } + State::Active { refs } => { + *self = State::Active { refs: refs + 1 }; + Acquire::Join + } + State::Lingering { .. } | State::Pinned => { + *self = State::Active { refs: 1 }; + Acquire::Reuse + } + } + } + + /// A session releases the slot. When the LAST session leaves, `now` + the resolved `linger` + /// decide the kept state. Returns what the registry should do. + pub fn release(&mut self, now: Instant, linger: Linger) -> Release { + match *self { + State::Active { refs } if refs > 1 => { + *self = State::Active { refs: refs - 1 }; + Release::Decref + } + State::Active { .. } => match linger { + Linger::Immediate => { + *self = State::Idle; + Release::Teardown + } + Linger::For(d) => { + *self = State::Lingering { until: now + d }; + Release::Linger + } + Linger::Forever => { + *self = State::Pinned; + Release::Pin + } + }, + // Releasing a slot with no live hold is a stale/duplicate release. The registry's + // gen-stamped leases already make a stale lease's drop a no-op before it reaches here; + // this is the defensive backstop. + State::Idle | State::Lingering { .. } | State::Pinned => Release::Noop, + } + } + + /// The registry's linger-timer tick: a Lingering slot past its deadline goes Idle and returns + /// `true` (the registry tears the display down). Pinned and every other state are untouched. + pub fn poll_expiry(&mut self, now: Instant) -> bool { + match *self { + State::Lingering { until } if now >= until => { + *self = State::Idle; + true + } + _ => false, + } + } + + /// Force-release a kept display (the `/display/release` endpoint): a Lingering/Pinned slot goes + /// Idle and the registry tears it down (`true`). An Active slot is refused (`false`) — releasing + /// a display that still has live sessions is session management, not display management. Idle → `false`. + pub fn force_release(&mut self) -> bool { + match *self { + State::Lingering { .. } | State::Pinned => { + *self = State::Idle; + true + } + State::Active { .. } | State::Idle => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn create_join_reuse_and_teardown() { + let mut s = State::default(); + assert_eq!(s.acquire(), Acquire::Create); + assert_eq!(s, State::Active { refs: 1 }); + // A concurrent session joins. + assert_eq!(s.acquire(), Acquire::Join); + assert_eq!(s.refs(), 2); + // One leaves — still active. + let now = Instant::now(); + assert_eq!(s.release(now, Linger::Immediate), Release::Decref); + assert_eq!(s.refs(), 1); + // The last leaves with keep-alive off — teardown. + assert_eq!(s.release(now, Linger::Immediate), Release::Teardown); + assert_eq!(s, State::Idle); + assert!(!s.has_display()); + } + + #[test] + fn linger_then_reuse_within_window() { + let mut s = State::default(); + let t0 = Instant::now(); + s.acquire(); + assert_eq!( + s.release(t0, Linger::For(Duration::from_secs(10))), + Release::Linger + ); + assert!(s.has_display()); + // A tick before the deadline does nothing. + assert!(!s.poll_expiry(t0 + Duration::from_secs(5))); + // A reconnect inside the window reuses the kept display. + assert_eq!(s.acquire(), Acquire::Reuse); + assert_eq!(s, State::Active { refs: 1 }); + } + + #[test] + fn linger_expires_to_teardown() { + let mut s = State::default(); + let t0 = Instant::now(); + s.acquire(); + s.release(t0, Linger::For(Duration::from_secs(10))); + // Past the deadline → teardown. + assert!(s.poll_expiry(t0 + Duration::from_secs(11))); + assert_eq!(s, State::Idle); + // A second tick is idempotent (nothing to tear down). + assert!(!s.poll_expiry(t0 + Duration::from_secs(12))); + } + + #[test] + fn pinned_never_expires_but_force_releases() { + let mut s = State::default(); + let t0 = Instant::now(); + s.acquire(); + assert_eq!(s.release(t0, Linger::Forever), Release::Pin); + assert_eq!(s, State::Pinned); + // No amount of ticking tears a pinned display down. + assert!(!s.poll_expiry(t0 + Duration::from_secs(86_400))); + assert!(s.has_display()); + // Only an explicit release does. + assert!(s.force_release()); + assert_eq!(s, State::Idle); + } + + #[test] + fn force_release_refuses_active() { + let mut s = State::default(); + s.acquire(); + assert!( + !s.force_release(), + "an active display can't be force-released" + ); + assert_eq!(s.refs(), 1); + // Idle also can't. + let mut idle = State::default(); + assert!(!idle.force_release()); + } + + #[test] + fn stale_release_is_noop() { + let mut s = State::default(); + assert_eq!(s.release(Instant::now(), Linger::Immediate), Release::Noop); + assert_eq!(s, State::Idle); + } + + /// Property test (deterministic seeded walk): across an arbitrary interleaving of acquire / + /// release / expiry-tick / force-release, the machine must never (a) leak or double-free the + /// backend resource — `has_display()` must exactly track a shadow "resource alive" flag, with + /// every Create preceded by no live resource and every teardown preceded by one — nor (b) + /// underflow the refcount, nor (c) tear a display down while a session still holds it. + #[test] + fn property_no_leaks_no_double_free_no_underflow() { + // Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency. + let mut rng: u64 = 0x1234_5678_9abc_def0; + let mut next = || { + rng = rng + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (rng >> 33) as u32 + }; + + let base = Instant::now(); + let mut logical_ms: u64 = 0; + let mut s = State::default(); + // Shadow model. + let mut resource_alive = false; + let mut live_holds: u32 = 0; + + for _ in 0..200_000 { + // Advance logical time by 0..2000 ms each step so lingers cross their deadlines. + logical_ms += (next() % 2000) as u64; + let now = base + Duration::from_millis(logical_ms); + + match next() % 5 { + 0 => { + // acquire + let before_alive = resource_alive; + let a = s.acquire(); + match a { + Acquire::Create => { + assert!(!before_alive, "Create while a resource was alive") + } + Acquire::Join | Acquire::Reuse => { + assert!(before_alive, "Join/Reuse with no live resource") + } + } + resource_alive = true; + live_holds += 1; + } + 1 | 2 => { + // release (weighted 2/5 so refs actually drain) + let linger = match next() % 3 { + 0 => Linger::Immediate, + 1 => Linger::For(Duration::from_millis((next() % 3000) as u64 + 1)), + _ => Linger::Forever, + }; + let held_before = live_holds; + let r = s.release(now, linger); + match r { + Release::Noop => assert_eq!(held_before, 0, "Noop only with no live hold"), + Release::Decref => { + assert!(held_before >= 2, "Decref must leave the display held"); + live_holds -= 1; + } + Release::Teardown => { + assert_eq!(held_before, 1, "Teardown only on the last hold"); + live_holds = 0; + resource_alive = false; + } + Release::Linger | Release::Pin => { + assert_eq!(held_before, 1, "Linger/Pin only on the last hold"); + live_holds = 0; + // resource stays alive (kept) + } + } + } + 3 => { + // expiry tick + if s.poll_expiry(now) { + assert_eq!(live_holds, 0, "expiry tore down a held display"); + resource_alive = false; + } + } + _ => { + // force release + if s.force_release() { + assert_eq!(live_holds, 0, "force-release tore down a held display"); + resource_alive = false; + } + } + } + + // Invariant after every step: the machine's own view of "a display exists" matches the + // shadow, and the refcount matches the live-hold count. + assert_eq!( + s.has_display(), + resource_alive, + "has_display drifted from the shadow model" + ); + assert_eq!( + s.refs(), + live_holds, + "refs drifted from the live-hold count" + ); + } + } +} diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs new file mode 100644 index 0000000..53cb189 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -0,0 +1,80 @@ +//! Neutral **facade over the per-OS virtual-display lifecycle owners**, for the management API's +//! `/display/state` + `/display/release` (design: `design/display-management.md` §7). +//! +//! Windows already owns its display lifecycle in [`super::manager::VirtualDisplayManager`] (one +//! shared IddCx monitor, refcounted, lingering); this facade reads and controls it. Linux keep-alive +//! (a per-session output pool driven by [`super::lifecycle`]) lands in a following increment — it +//! needs on-glass validation on a GPU box, which the current headless VM can't provide — so until +//! then the Linux side reports no managed displays and release is a no-op. +//! +//! The lifecycle *state machine* ([`super::lifecycle::State`]) is the platform-neutral core both +//! sides converge on; Windows adopts it when its manager is refactored onto it (that unification is +//! deferred so the on-glass-validated Windows path stays untouched this stage). + +/// One live or kept virtual display, for the mgmt snapshot. +#[derive(Clone, Debug)] +pub struct DisplayInfo { + /// A stable-enough id for the `/display/release` slot argument (the backend's generation stamp). + pub slot: u64, + /// Backend name (`"pf-vdisplay"`, `"kwin"`, …). + pub backend: String, + /// `(width, height, refresh_hz)`. + pub mode: (u32, u32, u32), + /// `"active"` | `"lingering"` | `"pinned"`. + pub state: String, + /// Milliseconds until a lingering display is torn down (`None` when active/pinned). + pub expires_in_ms: Option, + /// Live sessions holding the display. + pub sessions: u32, + /// Short client label (cert-fp prefix / peer), when the owner tracks it. + pub client: Option, +} + +/// The live display set for the mgmt `/display/state` endpoint. +#[derive(Clone, Debug, Default)] +pub struct Snapshot { + pub displays: Vec, +} + +/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read); +/// safe per management request. +pub fn snapshot() -> Snapshot { + #[cfg(target_os = "windows")] + { + let displays = super::manager::snapshot() + .map(|i| DisplayInfo { + slot: i.gen, + backend: i.backend.to_string(), + mode: i.mode, + state: i.state.to_string(), + expires_in_ms: i.expires_in_ms, + sessions: i.sessions, + client: None, + }) + .into_iter() + .collect(); + Snapshot { displays } + } + #[cfg(not(target_os = "windows"))] + { + // Linux keep-alive pool: not yet (needs GPU-box validation) — no managed displays to report. + Snapshot::default() + } +} + +/// Force-release kept (lingering/pinned) displays now — the `/display/release` endpoint. `slot` +/// selects one by [`DisplayInfo::slot`]; `None` releases every kept display. Active displays are +/// refused (releasing a display with live sessions is session management). Returns the number +/// released. +pub fn release(_slot: Option) -> usize { + #[cfg(target_os = "windows")] + { + // Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one + // lingering monitor if present. (Multi-monitor gives `slot` meaning later.) + usize::from(super::manager::force_release()) + } + #[cfg(not(target_os = "windows"))] + { + 0 + } +} diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index b7ffd7d..e6daa28 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -892,6 +892,81 @@ fn resolve_render_pin() -> Option { crate::win_adapter::resolve_render_adapter_luid() } +/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal: +/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade +/// maps it into the wire shape. +pub(crate) struct ManagedInfo { + pub backend: &'static str, + pub mode: (u32, u32, u32), + /// `"active"` | `"lingering"`. + pub state: &'static str, + /// Milliseconds until a lingering monitor is torn down (`None` when active). + pub expires_in_ms: Option, + /// Live sessions holding the monitor. + pub sessions: u32, + /// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg. + pub gen: u64, +} + +impl VirtualDisplayManager { + /// Snapshot the current monitor for the mgmt `/display/state` endpoint. `None` when Idle. + pub(crate) fn snapshot(&self) -> Option { + let st = self.state.lock().unwrap(); + let (mon, state, sessions, expires_in_ms) = match &*st { + MgrState::Idle => return None, + MgrState::Active { mon, refs } => (mon, "active", *refs, None), + MgrState::Lingering { mon, until } => { + let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64; + (mon, "lingering", 0u32, Some(ms)) + } + }; + Some(ManagedInfo { + backend: self.driver.name(), + mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz), + state, + expires_in_ms, + sessions, + gen: mon.gen, + }) + } + + /// Force-tear-down a LINGERING monitor now (the `/display/release` endpoint) — so a + /// physical-screen user gets their screen back without waiting out the linger. An Active monitor + /// is refused (stopping a live session is session management, not display management). Returns + /// `true` if a lingering monitor was released. + pub(crate) fn force_release(&self) -> bool { + let Some(dev) = self.device_handle() else { + return false; + }; + let mut st = self.state.lock().unwrap(); + if matches!(&*st, MgrState::Lingering { .. }) { + if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *st, MgrState::Idle) { + // SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()` + // (cached handles are never closed — a dead one is retired, kept alive; see + // `DeviceSlot`). `mon` was moved out of the `Lingering` state under the `state` lock, + // so it is exclusively owned here — no aliasing. + unsafe { self.teardown(dev, mon) }; + return true; + } + } + false + } +} + +/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no +/// session has ever run) or it is Idle. Safe to call per management request. +pub(crate) fn snapshot() -> Option { + VDM.get().and_then(VirtualDisplayManager::snapshot) +} + +/// Force-release a lingering monitor now; `false` if nothing was lingering (or the manager is +/// uninitialised). +pub(crate) fn force_release() -> bool { + VDM.get() + .map(VirtualDisplayManager::force_release) + .unwrap_or(false) +} + /// 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. diff --git a/web/messages/de.json b/web/messages/de.json index 2671942..215788f 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -68,6 +68,15 @@ "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.", + "display_live": "Aktive Displays", + "display_none_live": "Derzeit keine virtuellen Displays.", + "display_state_active": "Aktiv", + "display_state_lingering": "Wird gehalten", + "display_state_pinned": "Angeheftet", + "display_release_btn": "Freigeben", + "display_release_all": "Alle gehaltenen freigeben", + "display_expires_in": "Abbau in {sec}s", + "display_sessions": "{count} streamend", "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 4bc57c6..f4c4399 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -68,6 +68,15 @@ "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.", + "display_live": "Live displays", + "display_none_live": "No virtual displays right now.", + "display_state_active": "Active", + "display_state_lingering": "Lingering", + "display_state_pinned": "Pinned", + "display_release_btn": "Release", + "display_release_all": "Release all kept", + "display_expires_in": "tears down in {sec}s", + "display_sessions": "{count} streaming", "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 index 65f258c..4ba51b0 100644 --- a/web/src/sections/Host/DisplayCard.tsx +++ b/web/src/sections/Host/DisplayCard.tsx @@ -2,10 +2,14 @@ import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@unom/ui/button"; import { type FC, useEffect, useState } from "react"; import { + getGetDisplayStateQueryKey, getGetDisplaySettingsQueryKey, useGetDisplaySettings, + useGetDisplayState, + useReleaseDisplay, useSetDisplaySettings, } from "@/api/gen/display/display"; +import type { ApiDisplayInfo } from "@/api/gen/model"; import { ApiError } from "@/api/fetcher"; import type { DisplayPolicy, @@ -70,11 +74,100 @@ export const DisplaySection: FC = () => { /> )} + ); }; +/** + * The host's live/kept virtual displays, polled from `/display/state`, each with a Release button + * for lingering/pinned ones (active displays can't be released — that's session control). + */ +const LiveDisplays: FC = () => { + const qc = useQueryClient(); + const state = useGetDisplayState({ query: { refetchInterval: 2_000 } }); + const release = useReleaseDisplay(); + const displays = state.data?.displays ?? []; + const kept = displays.filter((d) => d.state !== "active"); + + const doRelease = (slot?: number) => + release.mutate( + { data: { slot: slot ?? null } }, + { onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) }, + ); + + return ( +
+
+

{m.display_live()}

+ {kept.length > 0 && ( + + )} +
+ {displays.length === 0 ? ( +

{m.display_none_live()}

+ ) : ( +
    + {displays.map((d) => ( + doRelease(d.slot)} + /> + ))} +
+ )} +
+ ); +}; + +const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({ + d, + busy, + onRelease, +}) => { + const active = d.state === "active"; + const stateLabel = + d.state === "active" + ? m.display_state_active() + : d.state === "pinned" + ? m.display_state_pinned() + : m.display_state_lingering(); + return ( +
  • +
    +
    + {d.mode} + {stateLabel} + {active && d.sessions > 0 && ( + {m.display_sessions({ count: d.sessions })} + )} +
    + + {d.backend} + {d.expires_in_ms != null + ? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}` + : ""} + +
    + {!active && ( + + )} +
  • + ); +}; + /** 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) { From 2dd17dda8027bfe9c239bd62a062b6ccc0c3c227 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 21:27:52 +0000 Subject: [PATCH 03/40] test(mgmt): display state/release endpoint smoke test Covers the idle path (empty /display/state + released:0 /display/release) on a unit-test host, exercising the wiring + auth without touching any global owner. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/mgmt.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 5c7fde1..b3ce773 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -2758,6 +2758,30 @@ mod tests { ); } + /// The display state/release endpoints are wired + auth-gated. On the test host no backend has + /// created a display (and non-Windows reports none), so `/state` is empty and `/release` is a + /// no-op — the shapes + the "nothing to release" path, without touching any global owner. + #[tokio::test] + async fn display_state_and_release_empty() { + let app = test_app(test_state(), None); + + let (status, body) = send(&app, get_req("/api/v1/display/state")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body["displays"].as_array().map(|a| a.len()), + Some(0), + "no managed displays on an idle test host" + ); + + let (status, body) = send( + &app, + post_json("/api/v1/display/release", serde_json::json!({})), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["released"], 0); + } + #[tokio::test] async fn native_pairing_arm_show_and_unpair() { let np = Arc::new( From 783c52dfadeba43ad93318ce80b7720c930701fc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 23:37:21 +0000 Subject: [PATCH 04/40] =?UTF-8?q?feat(vdisplay):=20Linux=20keep-alive=20po?= =?UTF-8?q?ol=20=E2=80=94=20registry-owned=20display=20lifecycle=20(Stage?= =?UTF-8?q?=201b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownership split (design/display-management.md §3): the registry owns the per-session virtual-display lifecycle on Linux, so a display can outlive its session (keep-alive) and be reused on reconnect. - registry.rs: a Linux pool driven by the pure lifecycle machine. acquire() reuses a kept (lingering/pinned) display of the same backend+mode, else creates one and keeps the backend's keepalive so the compositor output (and its PipeWire node_id) survives the session. The session's capturer holds a gen-stamped DisplayLease instead of the real keepalive; its drop drives linger/teardown. Enabling fact: KWin/Mutter/gamescope put their node on the DEFAULT PipeWire daemon (remote_fd=None) — reconnect re-attaches by node_id, no fd re-open. wlroots (remote_fd=Some, xdpw portal) passes through unchanged (teardown-on-drop) pending the fresh-portal-capture re-attach. - Default (unconfigured) linger = Immediate → today's teardown-on-disconnect, so no behavior change without a keep-alive policy; concurrent sessions still each create their own output (reuse only matches LINGERING entries). - Wired build_pipeline (punktfunk1) + gamestream through registry::acquire; capture_virtual_output signature unchanged. Windows delegates to vd.create (the manager already leases) — unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/gamestream/stream.rs | 10 +- crates/punktfunk-host/src/punktfunk1.rs | 6 +- .../punktfunk-host/src/vdisplay/registry.rs | 369 +++++++++++++++++- 3 files changed, 364 insertions(+), 21 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 50fb532..6e0ff79 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -286,13 +286,15 @@ fn open_gs_virtual_source( std::sync::atomic::AtomicBool::new(false), )) }); - let vout = vd - .create(punktfunk_core::Mode { + let vout = crate::vdisplay::registry::acquire( + &mut vd, + punktfunk_core::Mode { width: cfg.width, height: cfg.height, refresh_hz: cfg.fps, - }) - .context("create virtual output at client resolution")?; + }, + ) + .context("create virtual output at client resolution")?; // HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the // Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10 // PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index b0b593f..ef9f1ff 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -3508,7 +3508,11 @@ fn build_pipeline( bit_depth: u8, plan: crate::session_plan::SessionPlan, ) -> Result { - let vout = vd.create(mode).context("create virtual output")?; + // Acquire through the registry (design/display-management.md): on Linux this pools the display + // for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the + // session per policy); on Windows it delegates to `vd.create` (the manager already leases). The + // returned `VirtualOutput`'s keepalive is a registry lease — the capturer holds it as before. + let vout = crate::vdisplay::registry::acquire(vd, mode).context("create virtual output")?; // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a // virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame // clock to that, not the requested rate, so we don't emit phantom duplicate frames over a diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 53cb189..42acf58 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -1,22 +1,34 @@ -//! Neutral **facade over the per-OS virtual-display lifecycle owners**, for the management API's -//! `/display/state` + `/display/release` (design: `design/display-management.md` §7). +//! Host-lifetime **virtual-display registry** (design: `design/display-management.md` §3/§7): the +//! owner of the display lifecycle, so a display can outlive the session that created it (keep-alive) +//! and the management API can list + release kept displays. //! -//! Windows already owns its display lifecycle in [`super::manager::VirtualDisplayManager`] (one -//! shared IddCx monitor, refcounted, lingering); this facade reads and controls it. Linux keep-alive -//! (a per-session output pool driven by [`super::lifecycle`]) lands in a following increment — it -//! needs on-glass validation on a GPU box, which the current headless VM can't provide — so until -//! then the Linux side reports no managed displays and release is a no-op. +//! **Windows** already owns its lifecycle in [`super::manager::VirtualDisplayManager`] (one shared +//! IddCx monitor, refcounted, lingering); [`acquire`] there is a pass-through to `vd.create` (the +//! manager does the leasing), and [`snapshot`]/[`release`] read/control it. //! -//! The lifecycle *state machine* ([`super::lifecycle::State`]) is the platform-neutral core both -//! sides converge on; Windows adopts it when its manager is refactored onto it (that unification is -//! deferred so the on-glass-validated Windows path stays untouched this stage). +//! **Linux** gains a per-session **pool** here, driven by the pure [`super::lifecycle`] machine. The +//! key enabling fact: KWin / Mutter / gamescope put their capture node on the *default* PipeWire +//! daemon (`VirtualOutput::remote_fd == None`), reachable by `node_id` alone — so keeping the +//! backend's keepalive alive keeps the node alive, and a reconnect just re-attaches a fresh PipeWire +//! consumer to the same `node_id`. No fd dup / re-open needed. wlroots (`remote_fd == Some`, the +//! sandboxed xdpw portal) can't be kept without re-opening the portal fd per attach, so it is passed +//! through unchanged (teardown-on-drop, today's behavior) until that fresh-portal-capture re-attach +//! lands — a runtime gate on `remote_fd.is_some()`. +//! +//! The ownership split: the session's capturer no longer owns the real keepalive — the registry does. +//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, gen-stamped +//! `DisplayLease` (mirrors the Windows `MonitorLease`); dropping it releases the registry refcount, +//! and the lifecycle machine decides linger / teardown. `capture_virtual_output`'s signature is +//! unchanged — it just holds a lease instead of the real keepalive. + +use anyhow::Result; /// One live or kept virtual display, for the mgmt snapshot. #[derive(Clone, Debug)] pub struct DisplayInfo { - /// A stable-enough id for the `/display/release` slot argument (the backend's generation stamp). + /// A stable-enough id for the `/display/release` slot argument (the owner's generation stamp). pub slot: u64, - /// Backend name (`"pf-vdisplay"`, `"kwin"`, …). + /// Backend name (`"pf-vdisplay"`, `"kwin"`, `"mutter"`, …). pub backend: String, /// `(width, height, refresh_hz)`. pub mode: (u32, u32, u32), @@ -36,6 +48,27 @@ pub struct Snapshot { pub displays: Vec, } +/// Acquire a virtual display for a session: reuse a kept (lingering/pinned) display of the same +/// backend + mode if one exists, else create a fresh one. Returns a [`VirtualOutput`](super::VirtualOutput) +/// the capturer consumes as before — but its `keepalive` is a registry lease, so the *display* +/// outlives the capturer per the keep-alive policy. +/// +/// Windows delegates to the [`manager`](super::manager) via `vd.create` (unchanged); Linux uses the +/// pool below; other platforms pass through. +pub fn acquire( + vd: &mut Box, + mode: super::Mode, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::acquire(vd, mode) + } + #[cfg(not(target_os = "linux"))] + { + vd.create(mode) + } +} + /// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read); /// safe per management request. pub fn snapshot() -> Snapshot { @@ -55,9 +88,14 @@ pub fn snapshot() -> Snapshot { .collect(); Snapshot { displays } } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] + { + Snapshot { + displays: linux::snapshot(), + } + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] { - // Linux keep-alive pool: not yet (needs GPU-box validation) — no managed displays to report. Snapshot::default() } } @@ -66,15 +104,314 @@ pub fn snapshot() -> Snapshot { /// selects one by [`DisplayInfo::slot`]; `None` releases every kept display. Active displays are /// refused (releasing a display with live sessions is session management). Returns the number /// released. -pub fn release(_slot: Option) -> usize { +pub fn release(slot: Option) -> usize { #[cfg(target_os = "windows")] { // Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one // lingering monitor if present. (Multi-monitor gives `slot` meaning later.) + let _ = slot; usize::from(super::manager::force_release()) } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] { + linux::force_release(slot) + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + let _ = slot; 0 } } + +// --------------------------------------------------------------------------------------------- +// Linux keep-alive pool +// --------------------------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +mod linux { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Mutex, Once, OnceLock}; + use std::time::{Duration, Instant}; + + use anyhow::Result; + + use super::DisplayInfo; + use crate::vdisplay::lifecycle::{self, Acquire, Release}; + use crate::vdisplay::policy::{self, Linger}; + use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput}; + + /// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the + /// compositor output — and thus its PipeWire `node_id` — survives past the session), plus the + /// capture coordinates a reconnecting session needs. + struct Entry { + life: lifecycle::State, + /// The backend's keepalive (KWin Wayland conn / Mutter D-Bus session / gamescope child). Its + /// `Drop` releases the compositor output — so it is dropped only on teardown/expiry. + keepalive: Box, + node_id: u32, + preferred_mode: Option<(u32, u32, u32)>, + mode: Mode, + backend: &'static str, + /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease + /// — its entry was reused + re-stamped — is a no-op). + gen: u64, + } + + struct Reg { + entries: Mutex>, + gen: AtomicU64, + } + + static REG: OnceLock = OnceLock::new(); + + fn reg() -> &'static Reg { + REG.get_or_init(|| Reg { + entries: Mutex::new(Vec::new()), + gen: AtomicU64::new(1), + }) + } + + /// The linger resolution for Linux: the console policy's `keep_alive` when configured, else + /// **Immediate** (today's behavior — a Linux disconnect tears the output down at once). + fn linger() -> Linger { + policy::prefs() + .configured_effective() + .map(|e| e.keep_alive.linger()) + .unwrap_or(Linger::Immediate) + } + + /// Remove entries whose linger deadline has passed, returning them so the caller drops (tears + /// them down) *after* releasing the lock — a backend keepalive `Drop` (Mutter D-Bus Stop) can + /// block, and holding the pool lock across it would stall every other acquire/release. + fn take_expired(entries: &mut Vec, now: Instant) -> Vec { + let mut expired = Vec::new(); + let mut i = 0; + while i < entries.len() { + if entries[i].life.poll_expiry(now) { + expired.push(entries.remove(i)); + } else { + i += 1; + } + } + expired + } + + /// Background thread (started once): reap lingering displays past their deadline. + fn ensure_timer() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = std::thread::Builder::new() + .name("vdisplay-linger".into()) + .spawn(|| loop { + std::thread::sleep(Duration::from_millis(500)); + let expired = { + let mut es = reg().entries.lock().unwrap(); + take_expired(&mut es, Instant::now()) + }; + for e in expired { + tracing::info!( + backend = e.backend, + "virtual display: linger expired — torn down" + ); + drop(e); // outside the lock + } + }); + }); + } + + /// Build the session-facing [`VirtualOutput`]: the kept node + a fresh gen-stamped lease. Only + /// the poolable (`remote_fd == None`) backends reach here, so `remote_fd` is always `None`. + fn output_for( + node_id: u32, + preferred_mode: Option<(u32, u32, u32)>, + gen: u64, + ) -> VirtualOutput { + VirtualOutput { + node_id, + remote_fd: None, + preferred_mode, + keepalive: Box::new(DisplayLease { gen }), + } + } + + pub(super) fn acquire(vd: &mut Box, mode: Mode) -> Result { + ensure_timer(); + let backend = vd.name(); + let r = reg(); + + // Reap expired first (drop outside the lock). + let expired = { + let mut es = r.entries.lock().unwrap(); + take_expired(&mut es, Instant::now()) + }; + drop(expired); + + // Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session + // re-attaches a fresh PipeWire consumer to the still-live `node_id`. + { + let mut es = r.entries.lock().unwrap(); + if let Some(e) = es.iter_mut().find(|e| { + matches!( + e.life, + lifecycle::State::Lingering { .. } | lifecycle::State::Pinned + ) && e.backend == backend + && e.mode == mode + }) { + debug_assert_eq!(e.life.acquire(), Acquire::Reuse); + let gen = r.gen.fetch_add(1, Ordering::Relaxed); + e.gen = gen; + let out = output_for(e.node_id, e.preferred_mode, gen); + tracing::info!( + backend, + node_id = e.node_id, + "virtual display reused (keep-alive reconnect)" + ); + return Ok(out); + } + } + + // Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads). + let real = vd.create(mode)?; + + // wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the + // portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The + // poolable backends put their node on the default daemon (remote_fd = None). + if real.remote_fd.is_some() { + tracing::debug!( + backend, + "virtual display not poolable (portal fd) — keep-alive off for this backend" + ); + return Ok(real); + } + + let node_id = real.node_id; + let preferred_mode = real.preferred_mode; + let gen = r.gen.fetch_add(1, Ordering::Relaxed); + let mut life = lifecycle::State::default(); + debug_assert_eq!(life.acquire(), Acquire::Create); + let entry = Entry { + life, + keepalive: real.keepalive, + node_id, + preferred_mode, + mode, + backend, + gen, + }; + r.entries.lock().unwrap().push(entry); + Ok(output_for(node_id, preferred_mode, gen)) + } + + /// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The + /// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after* + /// the lock is released. + fn release(gen: u64) { + let Some(r) = REG.get() else { return }; + let linger = linger(); + let torn_down = { + let mut es = r.entries.lock().unwrap(); + let Some(idx) = es.iter().position(|e| e.gen == gen) else { + return; // stale lease (entry reused + re-stamped, or already gone) — no-op + }; + match es[idx].life.release(Instant::now(), linger) { + Release::Teardown | Release::Noop => Some(es.remove(idx)), + Release::Linger => { + tracing::info!( + backend = es[idx].backend, + "virtual display: last session left — lingering (keep-alive)" + ); + None + } + Release::Pin => { + tracing::info!( + backend = es[idx].backend, + "virtual display: last session left — pinned (keep-alive forever)" + ); + None + } + // Linux entries are single-session (refs == 1), so Decref never occurs; harmless. + Release::Decref => None, + } + }; + if let Some(e) = torn_down { + tracing::info!( + backend = e.backend, + "virtual display torn down (keep-alive off / released)" + ); + drop(e); // outside the lock — the keepalive Drop may block + } + } + + pub(super) fn snapshot() -> Vec { + let Some(r) = REG.get() else { + return Vec::new(); + }; + let now = Instant::now(); + r.entries + .lock() + .unwrap() + .iter() + .filter_map(|e| { + let (state, expires_in_ms, sessions) = match e.life { + lifecycle::State::Active { refs } => ("active", None, refs), + lifecycle::State::Lingering { until } => ( + "lingering", + Some(until.saturating_duration_since(now).as_millis() as u64), + 0, + ), + lifecycle::State::Pinned => ("pinned", None, 0), + // Idle entries are never stored (removed on teardown). + lifecycle::State::Idle => return None, + }; + Some(DisplayInfo { + slot: e.gen, + backend: e.backend.to_string(), + mode: (e.mode.width, e.mode.height, e.mode.refresh_hz), + state: state.to_string(), + expires_in_ms, + sessions, + client: None, + }) + }) + .collect() + } + + pub(super) fn force_release(slot: Option) -> usize { + let Some(r) = REG.get() else { return 0 }; + let released = { + let mut es = r.entries.lock().unwrap(); + let mut out = Vec::new(); + let mut i = 0; + while i < es.len() { + let selected = slot.is_none_or(|s| es[i].gen == s); + if selected && es[i].life.force_release() { + out.push(es.remove(i)); + } else { + i += 1; + } + } + out + }; + let n = released.len(); + for e in released { + tracing::info!( + backend = e.backend, + "virtual display released (mgmt /display/release)" + ); + drop(e); + } + n + } + + /// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the + /// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op. + struct DisplayLease { + gen: u64, + } + + impl Drop for DisplayLease { + fn drop(&mut self) { + release(self.gen); + } + } +} From 60816709c45f1a8655f6106838a2bb2deee6ab0d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 23:45:36 +0000 Subject: [PATCH 05/40] fix(vdisplay): call life.acquire() outside debug_assert (release no-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pooled entry's lifecycle transition was inside debug_assert_eq!, whose arguments don't evaluate in release builds — so acquire() never ran, the entry stayed Idle, and release saw Noop → immediate teardown (no keep-alive). Caught on-glass on the CachyOS box. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/vdisplay/registry.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 42acf58..977b89d 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -136,7 +136,7 @@ mod linux { use anyhow::Result; use super::DisplayInfo; - use crate::vdisplay::lifecycle::{self, Acquire, Release}; + use crate::vdisplay::lifecycle::{self, Release}; use crate::vdisplay::policy::{self, Linger}; use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput}; @@ -257,7 +257,8 @@ mod linux { ) && e.backend == backend && e.mode == mode }) { - debug_assert_eq!(e.life.acquire(), Acquire::Reuse); + // Lingering/Pinned → Active (Acquire::Reuse); side effect matters, value is known. + e.life.acquire(); let gen = r.gen.fetch_add(1, Ordering::Relaxed); e.gen = gen; let out = output_for(e.node_id, e.preferred_mode, gen); @@ -288,7 +289,7 @@ mod linux { let preferred_mode = real.preferred_mode; let gen = r.gen.fetch_add(1, Ordering::Relaxed); let mut life = lifecycle::State::default(); - debug_assert_eq!(life.acquire(), Acquire::Create); + life.acquire(); // Idle → Active{refs:1} (Acquire::Create) let entry = Entry { life, keepalive: real.keepalive, From cb7ddc0411fff70bdfa80be258adb15b62416e87 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 00:18:46 +0000 Subject: [PATCH 06/40] =?UTF-8?q?feat(vdisplay):=20topology=20decoupling?= =?UTF-8?q?=20=E2=80=94=20distinct=20primary=20level=20(Stage=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three topology levels become distinct behaviors (Stage 0 only did extend-vs-exclusive, faking primary): - vdisplay::effective_topology() -> the concrete level (console policy > legacy *_VIRTUAL_PRIMARY env > Auto default). Backends read it directly at create time; apply_session_env no longer writes the boolean env (one fewer connect- path env mutation). - Mutter: extend (no config), primary (virtual primary + physicals kept as secondaries — build_primary_keeping_physicals), exclusive (sole, physicals disabled). KWin: extend (no-op), primary (kscreen primary only), exclusive (primary + disable others). - Windows should_isolate treats primary as isolate (the primary-only CCD variant is a follow-up); wlroots exclusive + the physical-keep effect need a display-attached box (headless lab boxes can't observe primary vs exclusive). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/vdisplay.rs | 71 ++++++------ .../punktfunk-host/src/vdisplay/linux/kwin.rs | 53 +++++---- .../src/vdisplay/linux/mutter.rs | 101 ++++++++++++++---- 3 files changed, 143 insertions(+), 82 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 2a186e0..5d03d8c 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -403,44 +403,11 @@ pub fn apply_session_env(active: &ActiveSession) { if active.kind == ActiveKind::DesktopGnome { std::env::set_var("PUNKTFUNK_FORCE_SHM", "1"); } - // 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"). 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 => {} - } - } + // Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read + // [`effective_topology`] directly at create time — the console policy, else the legacy + // `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the + // auto-desktop path). So this connect-path no longer writes that env (one fewer process-env + // mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result. } #[cfg(not(target_os = "linux"))] pub fn apply_session_env(_active: &ActiveSession) {} @@ -779,6 +746,34 @@ pub fn resolve_topology(t: policy::Topology) -> policy::Topology { } } +/// The concrete display topology for the current session — what the per-compositor backends (and the +/// Windows isolate gate) apply at create time. Precedence, mirroring the rest of the policy surface: +/// the **console policy** when configured, else the legacy **`PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`** +/// env (an operator's explicit choice — `1`→exclusive, `0`→extend), else the **Auto** default +/// ([`resolve_topology`]: exclusive on the auto-detected desktop / Windows, extend under a compositor +/// pin). Always resolved (never [`policy::Topology::Auto`]). This is the Stage-2 replacement for the +/// `apply_session_env` boolean write — the backends read policy directly, so the `primary` level +/// (distinct from `exclusive`) becomes expressible and one process-env mutation drops off the connect +/// path. +pub fn effective_topology() -> policy::Topology { + if let Some(e) = policy::prefs().configured_effective() { + return resolve_topology(e.topology); + } + // Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at + // most one of these is set), else the Auto default. + let legacy = [ + "PUNKTFUNK_KWIN_VIRTUAL_PRIMARY", + "PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY", + ] + .iter() + .find_map(|k| std::env::var(k).ok()); + match legacy.as_deref().map(str::trim) { + Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive, + Some("0" | "false" | "no" | "off") => policy::Topology::Extend, + _ => resolve_topology(policy::Topology::Auto), + } +} + // Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA // backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat. #[cfg(target_os = "linux")] diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index a234d6a..efad859 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -111,14 +111,19 @@ impl VirtualDisplay for KwinDisplay { } else { mode.refresh_hz }; - // Make our streamed output the SOLE desktop: plasmashell + windows land on the surface we - // stream, not on the headless session's `kwin --virtual` bootstrap output (otherwise the - // client sees only the wallpaper of an empty extended output). Opt-in - // (PUNKTFUNK_KWIN_VIRTUAL_PRIMARY), mirroring the Mutter backend's PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY. - let restore = if virtual_primary_enabled() { - apply_virtual_primary() - } else { - Vec::new() + // Display-management topology (Stage 2): `Extend` leaves the streamed output an extension; + // `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled; + // `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so + // plasmashell + windows land on the streamed surface, not the headless `kwin --virtual` + // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). + use crate::vdisplay::policy::Topology; + let restore = match crate::vdisplay::effective_topology() { + Topology::Exclusive => apply_virtual_primary(), + Topology::Primary => { + apply_virtual_primary_only(); + Vec::new() // nothing disabled → nothing to restore + } + Topology::Extend | Topology::Auto => Vec::new(), }; Ok(VirtualOutput { node_id, @@ -213,21 +218,6 @@ fn read_active_refresh(output: &str) -> Option { Some(hz.round() as u32) } -/// Opt-in: make the per-session virtual output the sole desktop. Off by default — a host with no -/// competing output (or one that wants the bootstrap kept) is unaffected; the headless KDE appliance -/// (run-headless-kde.sh's `kwin --virtual` bootstrap + our streamed output) sets it so the desktop -/// renders on the streamed surface, not the bootstrap. Mirrors the Mutter backend's gate. -fn virtual_primary_enabled() -> bool { - std::env::var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY") - .map(|v| { - matches!( - v.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) - .unwrap_or(false) -} - /// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless /// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j` /// (same source as [`read_active_refresh`]). @@ -292,6 +282,23 @@ fn apply_virtual_primary() -> Vec { others } +/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled +/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a +/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing). +fn apply_virtual_primary_only() { + let ours = format!("Virtual-{VOUT_NAME}"); + let ok = std::process::Command::new("kscreen-doctor") + .arg(format!("output.{ours}.primary")) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + tracing::info!("KWin: streamed output set primary (physical outputs kept)"); + } else { + tracing::warn!("KWin: could not set the virtual output primary"); + } +} + /// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which /// drops the Wayland connection and makes KWin reclaim the output. struct StopGuard { diff --git a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs index 92714b7..3c51be5 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs @@ -118,9 +118,19 @@ fn session_thread(setup_tx: Sender>, stop: Arc, } }; rt.block_on(async move { - // Opt-in: snapshot the monitor layout BEFORE the virtual output exists, so we can tell the - // new (virtual) connector apart and restore the layout on teardown. Best-effort. - let dc_pre = if virtual_primary_enabled() { + // Display-management topology (Stage 2): the console policy's level, resolved to a concrete + // value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes + // it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the + // SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream. + let topo = crate::vdisplay::effective_topology(); + let want_config = matches!( + topo, + crate::vdisplay::policy::Topology::Primary | crate::vdisplay::policy::Topology::Exclusive + ); + let exclusive = matches!(topo, crate::vdisplay::policy::Topology::Exclusive); + // Snapshot the monitor layout BEFORE the virtual output exists (so we can tell the new + // connector apart and restore on teardown) whenever we're going to touch the topology. + let dc_pre = if want_config { match display_config().await { Ok(dc) => match get_state(&dc).await { Ok(state) => Some((dc, state)), @@ -152,8 +162,12 @@ fn session_thread(setup_tx: Sender>, stop: Arc, // monitor attached, the virtual output is an empty extended desktop — you stream only the // wallpaper. Best-effort: any failure just logs and streaming continues unchanged. if let Some((dc, pre)) = &dc_pre { - match make_virtual_primary(dc, mode, pre).await { - Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"), + match make_virtual_primary(dc, mode, pre, exclusive).await { + Ok(()) => tracing::info!( + exclusive, + "mutter: virtual output set as the primary monitor (physicals {})", + if exclusive { "disabled" } else { "kept" } + ), Err(e) => tracing::warn!( "mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor" ), @@ -338,17 +352,6 @@ type CurrentState = ( type ApplyMon = (String, String, HashMap>); // connector, mode_id, props type ApplyLogical = (i32, i32, f64, u32, bool, Vec); -fn virtual_primary_enabled() -> bool { - std::env::var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY") - .map(|v| { - matches!( - v.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) - .unwrap_or(false) -} - /// Opt-in: pin the virtual output to the client's exact refresh via RecordVirtual "modes" (true /// above-60 Hz). Off by default — Mutter-derived 60 Hz is safe on every host; high-refresh virtual /// CRTCs are validated on Mutter 50 + NVIDIA but behaviour can vary, so it stays opt-in. (The @@ -411,7 +414,12 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i /// which lands shortly after the node id), then make it the SOLE primary output (physicals /// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed /// surface. Restored on teardown. -async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentState) -> Result<()> { +async fn make_virtual_primary( + dc: &zbus::Proxy<'_>, + mode: Mode, + pre: &CurrentState, + exclusive: bool, +) -> Result<()> { let pre_conns = connectors(pre); let deadline = Instant::now() + Duration::from_secs(6); loop { @@ -437,7 +445,14 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta let Some(vmode) = vmode else { bail!("virtual monitor {vconn} has no usable mode yet"); }; - let config = build_primary_config(&vconn, &vmode); + // Exclusive: the virtual output alone (physicals omitted → Mutter disables them). + // Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries. + // (On a headless host with no physicals the two are identical.) + let config = if exclusive { + build_exclusive_config(&vconn, &vmode) + } else { + build_primary_keeping_physicals(&state, &vconn, &vmode, mode.width as i32) + }; let _: () = dc .call( "ApplyMonitorsConfig", @@ -459,12 +474,12 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta } } -/// The virtual output as the SOLE, primary monitor — physical outputs are omitted, so Mutter -/// disables them for the session. This confines the cursor, windows, and keyboard focus to the +/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so +/// Mutter disables them for the session. This confines the cursor, windows, and keyboard focus to the /// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative /// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to /// vanish). The physical layout is restored on teardown. -fn build_primary_config(vconn: &str, vmode: &str) -> Vec { +fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec { vec![( 0, 0, @@ -474,3 +489,47 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec { vec![(vconn.to_string(), vmode.to_string(), HashMap::new())], )] } + +/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical +/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its current mode). So +/// the shell + new windows land on the streamed surface, but the operator's physical screen stays +/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`]. +/// +/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep +/// on); the layout math is conservative (append to the right) but wants a display-attached box. +fn build_primary_keeping_physicals( + state: &CurrentState, + vconn: &str, + vmode: &str, + virt_width: i32, +) -> Vec { + let mut logicals: Vec = vec![( + 0, + 0, + 1.0, + 0, + true, + vec![(vconn.to_string(), vmode.to_string(), HashMap::new())], + )]; + // Append each physical (non-virtual) connector that has a usable current mode, to the right of + // the virtual output, as a non-primary secondary. + let mut x = virt_width.max(0); + for mon in &state.1 { + let conn = &mon.0 .0; + if conn == vconn { + continue; + } + if let Some((mode_id, w, _h)) = current_mode(state, conn) { + logicals.push(( + x, + 0, + 1.0, + 0, + false, + vec![(conn.clone(), mode_id, HashMap::new())], + )); + x += w.max(0); + } + } + logicals +} From b150d7962695d252ec71bec1f9ead927a2fb0910 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 08:40:18 +0000 Subject: [PATCH 07/40] feat(vdisplay): platform-neutral identity map + per-client-mode (Stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the Windows-only per-client stable-id map into vdisplay/identity.rs: - DisplayIdentityMap keyed on a composable string (identity_key: fingerprint, or fingerprint+resolution under per-client-mode); LRU at 15, persisted to display-identity.json (migrated from the legacy pf-vdisplay-identity.json). - Windows manager wired to it, picking the key from the identity policy. - Foundation for KWin per-slot output naming (persistent KDE scaling) — the KWin wiring is the next Stage-3 step (needs a KWin box). - Unit-tested (stable, per-client-mode split, LRU, key composition). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/vdisplay.rs | 7 +- .../punktfunk-host/src/vdisplay/identity.rs | 209 ++++++++++++++++++ .../src/vdisplay/windows/identity.rs | 172 -------------- .../src/vdisplay/windows/manager.rs | 19 +- 4 files changed, 229 insertions(+), 178 deletions(-) create mode 100644 crates/punktfunk-host/src/vdisplay/identity.rs delete mode 100644 crates/punktfunk-host/src/vdisplay/windows/identity.rs diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 5d03d8c..c34fb2d 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -779,8 +779,11 @@ pub fn effective_topology() -> policy::Topology { #[cfg(target_os = "linux")] #[path = "vdisplay/linux/gamescope.rs"] mod gamescope; -#[cfg(target_os = "windows")] -#[path = "vdisplay/windows/identity.rs"] +// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID + +// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows +// consumes it in non-test code today — the KWin wiring is the next Stage-3 step. +#[allow(dead_code)] +#[path = "vdisplay/identity.rs"] pub(crate) mod identity; #[cfg(target_os = "linux")] #[path = "vdisplay/linux/kwin.rs"] diff --git a/crates/punktfunk-host/src/vdisplay/identity.rs b/crates/punktfunk-host/src/vdisplay/identity.rs new file mode 100644 index 0000000..7f3d22b --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/identity.rs @@ -0,0 +1,209 @@ +//! Platform-neutral **per-client → stable display-id map** (design: `design/display-management.md` +//! §5.4 — identity). A client that reconnects gets the SAME small stable id every time, so the +//! desktop environment can key its per-display config (notably **DPI scaling**) to it and reapply it: +//! +//! * **Windows** seeds the pf-vdisplay monitor's EDID serial + IddCx `ConnectorIndex` from the id, so +//! Windows reapplies the client's saved `PerMonitorSettings` scaling. The id must stay `1..=15` +//! (`ConnectorIndex < MaxMonitorsSupported = 16`). +//! * **KWin** names the streamed output `Virtual-punktfunk-`; KWin persists per-output scale/mode +//! in `kwinoutputconfig.json` matched by name, so a stable per-client name makes KDE reapply that +//! client's scaling. (Generalised here from the Windows-only map; the KWin wiring is Stage 3.) +//! +//! The map key is a composable string ([`identity_key`]): the client cert fingerprint alone +//! (`per-client`), or fingerprint + resolution (`per-client-mode` — distinct scaling per resolution). +//! Anonymous/TOFU/GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream, +//! never reaching this map. +//! +//! Persisted to `/display-identity.json` (migrated from the legacy Windows +//! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Max stable id. Bounded by the Windows driver's use of the id as the IddCx `ConnectorIndex` +/// (`< MaxMonitorsSupported = 16`), so ids run `1..=15` on every platform for a single shared map. +const MAX_ID: u32 = 15; + +/// The map filename (migrated from the legacy Windows-only `pf-vdisplay-identity.json`). +const FILE: &str = "display-identity.json"; +const LEGACY_FILE: &str = "pf-vdisplay-identity.json"; + +/// Compose the map key for a client. `per_client_mode` appends the resolution so a client keeps a +/// distinct id (and thus distinct persisted scaling) per resolution; otherwise the fingerprint alone. +pub(crate) fn identity_key(fp: [u8; 32], mode: (u32, u32), per_client_mode: bool) -> String { + let hex: String = fp.iter().map(|b| format!("{b:02x}")).collect(); + if per_client_mode { + format!("{hex}@{}x{}", mode.0, mode.1) + } else { + hex + } +} + +#[derive(Serialize, Deserialize, Default)] +struct Store { + /// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so + /// the LRU ordering survives host restarts. + tick: u64, + entries: Vec, +} + +#[derive(Serialize, Deserialize)] +struct Entry { + /// The composed client key ([`identity_key`]) — the map key. (Serialized as `fp` for + /// back-compat with the legacy Windows `pf-vdisplay-identity.json`.) + #[serde(rename = "fp")] + key: String, + /// The client's stable display id (`1..=15`). + id: u32, + /// MRU stamp (compared against [`Store::tick`]). + seen: u64, +} + +/// Persistent client-key → stable-id map (see the module docs). +pub(crate) struct DisplayIdentityMap { + path: PathBuf, + store: Store, +} + +impl DisplayIdentityMap { + /// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just + /// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy + /// Windows `pf-vdisplay-identity.json` if the new file is absent. + pub(crate) fn load() -> Self { + let dir = crate::gamestream::config_dir(); + let path = dir.join(FILE); + let bytes = std::fs::read(&path) + .or_else(|_| std::fs::read(dir.join(LEGACY_FILE))) + .ok(); + let mut store = bytes + .and_then(|b| serde_json::from_slice::(&b).ok()) + .unwrap_or_default(); + // SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s + // found-entry branch returns the stored id verbatim, so an out-of-range id (0 = the "auto" + // sentinel, or > MAX_ID) or a duplicate id/key would flow straight into the display identity. + // Drop out-of-range ids and dedup by BOTH key and id (keeping the most-recently-seen on a + // clash) so no two clients can map to the same id. + store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen)); + let mut seen_key = std::collections::HashSet::new(); + let mut seen_id = std::collections::HashSet::new(); + store.entries.retain(|e| { + (1..=MAX_ID).contains(&e.id) && seen_key.insert(e.key.clone()) && seen_id.insert(e.id) + }); + Self { path, store } + } + + /// The stable id (`1..=15`) for the client `key` ([`identity_key`]): its remembered id, or a + /// freshly assigned one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists. + pub(crate) fn resolve(&mut self, key: &str) -> u32 { + self.store.tick = self.store.tick.wrapping_add(1); + let now = self.store.tick; + + if let Some(e) = self.store.entries.iter_mut().find(|e| e.key == key) { + e.seen = now; + let id = e.id; + self.persist(); + return id; + } + + // New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and + // reuse its id (the evicted client re-establishes its scaling once on its next connect). + let id = (1..=MAX_ID) + .find(|i| !self.store.entries.iter().any(|e| e.id == *i)) + .unwrap_or_else(|| { + let lru = self + .store + .entries + .iter() + .enumerate() + .min_by_key(|(_, e)| e.seen) + .map(|(i, _)| i) + .expect("entries are non-empty whenever every id 1..=MAX_ID is taken"); + let evicted = self.store.entries.remove(lru); + evicted.id + }); + self.store.entries.push(Entry { + key: key.to_string(), + id, + seen: now, + }); + self.persist(); + id + } + + /// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may + /// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine. + fn persist(&self) { + let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else { + return; + }; + if let Some(dir) = self.path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = self.path.with_extension("json.tmp"); + if std::fs::write(&tmp, &bytes).is_ok() { + let _ = std::fs::rename(&tmp, &self.path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fp(n: u8) -> [u8; 32] { + let mut f = [0u8; 32]; + f[0] = n; + f + } + + fn temp_map(tag: &str) -> DisplayIdentityMap { + DisplayIdentityMap { + path: std::env::temp_dir().join(format!("pf-id-{tag}-{}.json", std::process::id())), + store: Store::default(), + } + } + + #[test] + fn stable_across_calls_and_distinct_per_client() { + let mut m = temp_map("stable"); + let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false)); + let b = m.resolve(&identity_key(fp(2), (1920, 1080), false)); + let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false)); // per-client: mode ignored + assert_eq!(a1, a2, "same client → same id (per-client ignores mode)"); + assert_ne!(a1, b, "distinct clients → distinct ids"); + assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b)); + let _ = std::fs::remove_file(&m.path); + } + + #[test] + fn per_client_mode_splits_by_resolution() { + let mut m = temp_map("permode"); + let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true)); + let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true)); + let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true)); + assert_ne!(hd, uhd, "same client, different resolution → different id"); + assert_eq!(hd, hd2, "same client + resolution → same id"); + let _ = std::fs::remove_file(&m.path); + } + + #[test] + fn lru_eviction_reuses_an_id_at_the_cap() { + let mut m = temp_map("lru"); + for n in 1..=15u8 { + m.resolve(&identity_key(fp(n), (1920, 1080), false)); + } + let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false)); // touch 2 so 1 is LRU + let id16 = m.resolve(&identity_key(fp(16), (1920, 1080), false)); + assert!((1..=MAX_ID).contains(&id16)); + assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries"); + assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id))); + let _ = std::fs::remove_file(&m.path); + } + + #[test] + fn key_composition() { + assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only + assert!(identity_key(fp(0xab), (1920, 1080), true).ends_with("@1920x1080")); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/windows/identity.rs b/crates/punktfunk-host/src/vdisplay/windows/identity.rs deleted file mode 100644 index 24c4201..0000000 --- a/crates/punktfunk-host/src/vdisplay/windows/identity.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Per-client → stable monitor-id map for pf-vdisplay (Phase 2: per-client display-config persistence). -//! -//! Windows keys per-monitor config — notably DPI **scaling** (`HKCU\Control Panel\Desktop\PerMonitorSettings`) -//! — on the monitor's EDID identity AND its OS device path (whose per-connector discriminator is the IddCx -//! `ConnectorIndex` → target UID). The pf-vdisplay driver seeds BOTH the EDID serial and the `ConnectorIndex` -//! from a single monitor `id`. So for Windows to REAPPLY a given client's saved scaling on reconnect, that -//! client must get the SAME `id` every time. This map assigns each client (keyed by its cert fingerprint) a -//! STABLE id and the host passes it as [`AddRequest::preferred_monitor_id`](pf_driver_proto::control::AddRequest). -//! -//! The id space is bounded to `1..=15` because the driver uses the id as the IddCx `ConnectorIndex`, which -//! must stay `< MaxMonitorsSupported` (16). When more than 15 distinct clients are remembered, the -//! LEAST-RECENTLY-USED entry is evicted and its id reused (that evicted client simply re-establishes its -//! scaling once on its next connect). The map persists to `%ProgramData%\punktfunk\pf-vdisplay-identity.json` -//! so ids — and therefore the client→config association — survive host restarts. -//! -//! Anonymous/TOFU and GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream, never -//! reaching this map — they keep the driver's lowest-free slot behavior unchanged. - -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -/// Max stable id. The driver uses the id as the IddCx `ConnectorIndex`, which must stay -/// `< MaxMonitorsSupported` (16) — so ids run `1..=15`. -const MAX_ID: u32 = 15; - -#[derive(Serialize, Deserialize, Default)] -struct Store { - /// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so - /// the LRU ordering survives host restarts. - tick: u64, - entries: Vec, -} - -#[derive(Serialize, Deserialize)] -struct Entry { - /// Lower-hex client cert fingerprint (the map key). - fp: String, - /// The client's stable monitor id (`1..=15`). - id: u32, - /// MRU stamp (compared against [`Store::tick`]). - seen: u64, -} - -/// Persistent fingerprint → stable-id map (see the module docs). -pub(crate) struct MonitorIdentityMap { - path: PathBuf, - store: Store, -} - -impl MonitorIdentityMap { - /// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just - /// re-derives ids, costing a client one scaling re-set the first time). - pub(crate) fn load() -> Self { - let path = crate::gamestream::config_dir().join("pf-vdisplay-identity.json"); - let mut store = std::fs::read(&path) - .ok() - .and_then(|b| serde_json::from_slice::(&b).ok()) - .unwrap_or_default(); - // SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s found-entry - // branch returns the stored id verbatim, so an out-of-range id (0 = the "auto" sentinel, or - // > MAX_ID) or a duplicate id/fp would flow straight into preferred_monitor_id. Drop out-of-range - // ids and dedup by BOTH fp and id (keeping the most-recently-seen on a clash) so no two fingerprints - // can map to the same id. (The driver also rejects a live-colliding id as a backstop.) - store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen)); - let mut seen_fp = std::collections::HashSet::new(); - let mut seen_id = std::collections::HashSet::new(); - store.entries.retain(|e| { - (1..=MAX_ID).contains(&e.id) && seen_fp.insert(e.fp.clone()) && seen_id.insert(e.id) - }); - Self { path, store } - } - - /// The stable id (`1..=15`) for the client fingerprint `fp`: its remembered id, or a freshly assigned - /// one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists. - pub(crate) fn resolve(&mut self, fp: [u8; 32]) -> u32 { - let key: String = fp.iter().map(|b| format!("{b:02x}")).collect(); - self.store.tick = self.store.tick.wrapping_add(1); - let now = self.store.tick; - - if let Some(e) = self.store.entries.iter_mut().find(|e| e.fp == key) { - e.seen = now; - let id = e.id; - self.persist(); - return id; - } - - // New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and - // reuse its id (the evicted client re-establishes its scaling once on its next connect). - let id = (1..=MAX_ID) - .find(|i| !self.store.entries.iter().any(|e| e.id == *i)) - .unwrap_or_else(|| { - let lru = self - .store - .entries - .iter() - .enumerate() - .min_by_key(|(_, e)| e.seen) - .map(|(i, _)| i) - .expect("entries are non-empty whenever every id 1..=MAX_ID is taken"); - let evicted = self.store.entries.remove(lru); - evicted.id - }); - self.store.entries.push(Entry { - fp: key, - id, - seen: now, - }); - self.persist(); - id - } - - /// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may - /// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine. - fn persist(&self) { - let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else { - return; - }; - if let Some(dir) = self.path.parent() { - let _ = std::fs::create_dir_all(dir); - } - let tmp = self.path.with_extension("json.tmp"); - if std::fs::write(&tmp, &bytes).is_ok() { - let _ = std::fs::rename(&tmp, &self.path); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fp(n: u8) -> [u8; 32] { - let mut f = [0u8; 32]; - f[0] = n; - f - } - - #[test] - fn stable_across_calls_and_distinct_per_client() { - let mut m = MonitorIdentityMap { - path: std::env::temp_dir().join(format!("pf-id-test-{}.json", std::process::id())), - store: Store::default(), - }; - let a1 = m.resolve(fp(1)); - let b = m.resolve(fp(2)); - let a2 = m.resolve(fp(1)); - assert_eq!(a1, a2, "same client → same id"); - assert_ne!(a1, b, "distinct clients → distinct ids"); - assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b)); - let _ = std::fs::remove_file(&m.path); - } - - #[test] - fn lru_eviction_reuses_an_id_at_the_cap() { - let mut m = MonitorIdentityMap { - path: std::env::temp_dir().join(format!("pf-id-lru-{}.json", std::process::id())), - store: Store::default(), - }; - // Fill all 15 ids (clients 1..=15), then touch client 2 so client 1 is the LRU. - for n in 1..=15u8 { - m.resolve(fp(n)); - } - let _ = m.resolve(fp(2)); - // A 16th client evicts the LRU (client 1) and reuses its id; ids stay bounded. - let id16 = m.resolve(fp(16)); - assert!((1..=MAX_ID).contains(&id16)); - assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries"); - assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id))); - let _ = std::fs::remove_file(&m.path); - } -} diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index e6daa28..fe1c168 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -172,7 +172,7 @@ pub(crate) struct VirtualDisplayManager { /// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the /// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across /// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`]. - identity_map: Mutex, + identity_map: Mutex, } static VDM: OnceLock = OnceLock::new(); @@ -188,7 +188,7 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa state: Mutex::new(MgrState::Idle), setup_lock: Mutex::new(()), idd_session_stop: Mutex::new(None), - identity_map: Mutex::new(super::identity::MonitorIdentityMap::load()), + identity_map: Mutex::new(super::identity::DisplayIdentityMap::load()), }) } @@ -527,9 +527,20 @@ impl VirtualDisplayManager { ) -> Result { // Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved // per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver - // auto-allocates the lowest-free id (the original slot-based behavior). + // auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy + // picks the key: per-client (fingerprint) or per-client-mode (fingerprint + resolution). + let per_client_mode = matches!( + crate::vdisplay::policy::prefs() + .configured_effective() + .map(|e| e.identity), + Some(crate::vdisplay::policy::Identity::PerClientMode) + ); let preferred_id = client_fp - .map(|fp| self.identity_map.lock().unwrap().resolve(fp)) + .map(|fp| { + let key = + super::identity::identity_key(fp, (mode.width, mode.height), per_client_mode); + self.identity_map.lock().unwrap().resolve(&key) + }) .unwrap_or(0); // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. From d73951414ce3497a2ac981680f0b487a81f4ea6c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 08:54:39 +0000 Subject: [PATCH 08/40] feat(vdisplay): KWin per-slot output naming for persistent scaling (Stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KWin backend names its output Virtual-punktfunk- from the client's stable identity slot, so KWin persists per-output config (scale/mode) by name in kwinoutputconfig.json and reapplies that client's scaling on reconnect — the KDE scaling ask. Also fixes the latent clash where two concurrent sessions both used Virtual-punktfunk (topology name-matching now uses the per-slot name). - identity::global() + resolve_slot(fp, mode, default) — the shared persisted map (Windows manager dropped its own field; both use the global — never same-process). Default identity is per-platform: PerClient on Windows, Shared on Linux, so unconfigured hosts keep today's behavior (Linux = single 'punktfunk' name). - KwinDisplay carries the client fp (set_client_identity), computes the per-slot name, threads it through the stream_virtual_output name + the topology helpers (set_custom_refresh / apply_virtual_primary[_only] / other_enabled_outputs). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/vdisplay/identity.rs | 37 +++++++++++ .../punktfunk-host/src/vdisplay/linux/kwin.rs | 64 +++++++++++++------ .../src/vdisplay/windows/manager.rs | 30 ++++----- 3 files changed, 93 insertions(+), 38 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/identity.rs b/crates/punktfunk-host/src/vdisplay/identity.rs index 7f3d22b..83de969 100644 --- a/crates/punktfunk-host/src/vdisplay/identity.rs +++ b/crates/punktfunk-host/src/vdisplay/identity.rs @@ -18,6 +18,7 @@ //! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts. use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; use serde::{Deserialize, Serialize}; @@ -147,6 +148,42 @@ impl DisplayIdentityMap { } } +/// The process-wide identity map (persisted, loaded once). Shared by the Windows manager and the +/// Linux KWin backend — never in the same process (a host runs one platform), so one instance ⇒ no +/// clobbering of the shared `display-identity.json`. +pub(crate) fn global() -> &'static Mutex { + static MAP: OnceLock> = OnceLock::new(); + MAP.get_or_init(|| Mutex::new(DisplayIdentityMap::load())) +} + +/// Resolve the connecting client's stable slot id per the `identity` policy. When no policy is +/// configured, `default` applies — **PerClient on Windows / Shared on Linux**, preserving each +/// platform's historical behavior (Windows always keyed monitors per-client; Linux used one shared +/// output name). `None` ⇒ shared / anonymous → the backend uses its base name / auto slot. +pub(crate) fn resolve_slot( + fp: Option<[u8; 32]>, + mode: (u32, u32), + default: crate::vdisplay::policy::Identity, +) -> Option { + use crate::vdisplay::policy::Identity; + let id_policy = crate::vdisplay::policy::prefs() + .configured_effective() + .map(|e| e.identity) + .unwrap_or(default); + let per_client_mode = match id_policy { + Identity::Shared => return None, + Identity::PerClient => false, + Identity::PerClientMode => true, + }; + let fp = fp?; + Some( + global() + .lock() + .unwrap() + .resolve(&identity_key(fp, mode, per_client_mode)), + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index efad859..a9fb20a 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -67,13 +67,19 @@ const VOUT_NAME: &str = "punktfunk"; /// event (deprecated only since v6) for the node id, so cap the bind at 5. const MAX_VERSION: u32 = 5; -/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up -/// its own Wayland connection/thread that owns the resulting output. -pub struct KwinDisplay; +/// The KWin virtual-display driver. Carries the connecting client's cert fingerprint (set before +/// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME +/// (`Virtual-punktfunk-`) — KWin persists per-output config (scale/mode) keyed by name in +/// `kwinoutputconfig.json`, so a stable name makes KDE reapply that client's scaling on reconnect +/// (Stage 3). Each `create` spins up its own Wayland connection/thread that owns the output. +#[derive(Default)] +pub struct KwinDisplay { + client_fp: Option<[u8; 32]>, +} impl KwinDisplay { pub fn new() -> Result { - Ok(KwinDisplay) + Ok(KwinDisplay::default()) } } @@ -82,14 +88,32 @@ impl VirtualDisplay for KwinDisplay { "kwin" } + fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) { + self.client_fp = fingerprint; + } + fn create(&mut self, mode: Mode) -> Result { + // Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id → + // `punktfunk-` (KWin exposes `Virtual-punktfunk-`, whose per-output config KWin + // persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux + // defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND + // it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`. + let name = match crate::vdisplay::identity::resolve_slot( + self.client_fp, + (mode.width, mode.height), + crate::vdisplay::policy::Identity::Shared, + ) { + Some(id) => format!("{VOUT_NAME}-{id}"), + None => VOUT_NAME.to_string(), + }; let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); let (width, height) = (mode.width, mode.height); + let name_thread = name.clone(); thread::Builder::new() .name("punktfunk-kwin-vout".into()) - .spawn(move || virtual_output_thread(width, height, setup_tx, stop_thread)) + .spawn(move || virtual_output_thread(width, height, name_thread, setup_tx, stop_thread)) .context("spawn KWin virtual-output thread")?; let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { @@ -107,7 +131,7 @@ impl VirtualDisplay for KwinDisplay { // rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install — // the source runs 60 Hz and the encoder downsamples — so carry the requested rate through. let achieved_hz = if mode.refresh_hz > 60 { - set_custom_refresh(width, height, mode.refresh_hz) + set_custom_refresh(width, height, mode.refresh_hz, &name) } else { mode.refresh_hz }; @@ -118,9 +142,9 @@ impl VirtualDisplay for KwinDisplay { // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). use crate::vdisplay::policy::Topology; let restore = match crate::vdisplay::effective_topology() { - Topology::Exclusive => apply_virtual_primary(), + Topology::Exclusive => apply_virtual_primary(&name), Topology::Primary => { - apply_virtual_primary_only(); + apply_virtual_primary_only(&name); Vec::new() // nothing disabled → nothing to restore } Topology::Extend | Topology::Auto => Vec::new(), @@ -140,8 +164,8 @@ impl VirtualDisplay for KwinDisplay { /// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected), /// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller /// paces the encoder to the *achieved* rate, not the requested one. -fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 { - let output = format!("Virtual-{VOUT_NAME}"); +fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 { + let output = format!("Virtual-{name}"); let mhz = hz.saturating_mul(1000); let run = |arg: String| { std::process::Command::new("kscreen-doctor") @@ -221,8 +245,8 @@ fn read_active_refresh(output: &str) -> Option { /// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless /// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j` /// (same source as [`read_active_refresh`]). -fn other_enabled_outputs() -> Vec { - let ours = format!("Virtual-{VOUT_NAME}"); +fn other_enabled_outputs(name: &str) -> Vec { + let ours = format!("Virtual-{name}"); let out = match std::process::Command::new("kscreen-doctor") .arg("-j") .output() @@ -252,8 +276,8 @@ fn other_enabled_outputs() -> Vec { /// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the /// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly /// showing only the wallpaper) rather than failing the session. -fn apply_virtual_primary() -> Vec { - let ours = format!("Virtual-{VOUT_NAME}"); +fn apply_virtual_primary(name: &str) -> Vec { + let ours = format!("Virtual-{name}"); let kscreen = |args: &[String]| { std::process::Command::new("kscreen-doctor") .args(args) @@ -270,7 +294,7 @@ fn apply_virtual_primary() -> Vec { ); } std::thread::sleep(Duration::from_millis(200)); - let others = other_enabled_outputs(); + let others = other_enabled_outputs(name); if !others.is_empty() { let args: Vec = others .iter() @@ -285,8 +309,8 @@ fn apply_virtual_primary() -> Vec { /// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled /// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a /// physical screen stays usable. Nothing to restore on teardown (we disabled nothing). -fn apply_virtual_primary_only() { - let ours = format!("Virtual-{VOUT_NAME}"); +fn apply_virtual_primary_only(name: &str) { + let ours = format!("Virtual-{name}"); let ok = std::process::Command::new("kscreen-doctor") .arg(format!("output.{ours}.primary")) .status() @@ -395,10 +419,11 @@ impl Dispatch for State { fn virtual_output_thread( width: u32, height: u32, + name: String, setup_tx: Sender>, stop: Arc, ) { - if let Err(e) = run(width, height, &setup_tx, &stop) { + if let Err(e) = run(width, height, &name, &setup_tx, &stop) { // If we never delivered a node id, report the failure to the waiting opener. let _ = setup_tx.send(Err(format!("{e:#}"))); } @@ -438,6 +463,7 @@ pub fn is_available() -> bool { fn run( width: u32, height: u32, + name: &str, setup_tx: &Sender>, stop: &AtomicBool, ) -> Result<()> { @@ -460,7 +486,7 @@ fn run( // Create the virtual output sized to the client, cursor composited into the stream. let stream = screencast.stream_virtual_output( - VOUT_NAME.to_string(), + name.to_string(), width as i32, height as i32, 1.0, // scale (logical == physical) diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index fe1c168..23e6198 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -169,10 +169,10 @@ pub(crate) struct VirtualDisplayManager { /// The current IDD-push session's stop flag; a new connection signals the prior one to release its /// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`). idd_session_stop: Mutex>>, - /// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the - /// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across - /// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`]. - identity_map: Mutex, + // The per-client stable monitor-id map is now the process-wide `super::identity::global()` + // (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE + // resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx + // ConnectorIndex across reconnects and Windows reapplies its saved per-monitor DPI scaling. } static VDM: OnceLock = OnceLock::new(); @@ -188,7 +188,6 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa state: Mutex::new(MgrState::Idle), setup_lock: Mutex::new(()), idd_session_stop: Mutex::new(None), - identity_map: Mutex::new(super::identity::DisplayIdentityMap::load()), }) } @@ -528,20 +527,13 @@ impl VirtualDisplayManager { // Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved // per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver // auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy - // picks the key: per-client (fingerprint) or per-client-mode (fingerprint + resolution). - let per_client_mode = matches!( - crate::vdisplay::policy::prefs() - .configured_effective() - .map(|e| e.identity), - Some(crate::vdisplay::policy::Identity::PerClientMode) - ); - let preferred_id = client_fp - .map(|fp| { - let key = - super::identity::identity_key(fp, (mode.width, mode.height), per_client_mode); - self.identity_map.lock().unwrap().resolve(&key) - }) - .unwrap_or(0); + // picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior). + let preferred_id = super::identity::resolve_slot( + client_fp, + (mode.width, mode.height), + crate::vdisplay::policy::Identity::PerClient, + ) + .unwrap_or(0); // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. // `resolve_render_pin()` returns an `Option` by value (plain `Copy`), so no borrowed From eda7cac78ea78f6f2a9111da05e1eed7e35b9d7b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 09:17:41 +0000 Subject: [PATCH 09/40] =?UTF-8?q?feat(vdisplay/windows):=20topology=3Dprim?= =?UTF-8?q?ary=20=E2=80=94=20keep=20physicals=20active,=20virtual=20primar?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the deferred Windows primary-only CCD (Stage 2). set_virtual_primary_ccd repositions the virtual output's source to (0,0) = primary and shifts the physical display(s) to its right, ALL kept active — one atomic CCD SetDisplayConfig (not GDI CDS_SET_PRIMARY, which storms MODE_CHANGE_IN_PROGRESS with another display live). The manager's should_isolate() becomes topology_action() (3-way): extend (skip), primary (set_virtual_primary_ccd), exclusive (isolate_displays_ccd). Restore-on-teardown covers both. Validates the user's two scenarios on a physical-monitor .173. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/vdisplay/windows/manager.rs | 64 ++++++++------ .../punktfunk-host/src/windows/win_display.rs | 84 ++++++++++++++++++- 2 files changed, 122 insertions(+), 26 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 23e6198..e60aa20 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -34,7 +34,7 @@ use windows::Win32::System::Threading::{ use super::{Mode, VirtualOutput}; use crate::win_display::{ force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, - set_active_mode, SavedConfig, + set_active_mode, set_virtual_primary_ccd, SavedConfig, }; /// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by @@ -633,19 +633,28 @@ impl VirtualDisplayManager { tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id); // ADD only advertises the mode; force it active so DXGI captures the requested size. set_active_mode(n, mode); - // Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD - // 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 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 (topology=extend / PUNKTFUNK_NO_ISOLATE) — IDD stays extended" - ); + // Apply the display-management topology (Stage 2). `Exclusive` (default) deactivates the + // other display(s) so the IDD is the SOLE composited primary — an EXTENDED (non-primary) + // IDD isn't DWM-composited on this box → Desktop Duplication born-losts. `Primary` keeps the + // physical display(s) ACTIVE and makes the IDD primary (repositioned to origin). `Extend` + // leaves it a plain extension. Both isolate + primary go through the atomic CCD path (no + // MODE_CHANGE storm). Opt out (extend) with PUNKTFUNK_NO_ISOLATE=1 / the console policy. + use crate::vdisplay::policy::Topology; + match topology_action() { + // SAFETY (both arms): the CCD helper is `unsafe` for its topology FFI; it takes a + // `Copy` `u32` by value and returns an owned `SavedConfig` (no borrowed memory crosses), + // and runs under the `state` lock, the sole mutator of the topology. + Topology::Exclusive => { + ccd_saved = unsafe { isolate_displays_ccd(added.target_id) }; + } + Topology::Primary => { + ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) }; + } + Topology::Extend | Topology::Auto => { + tracing::info!( + "display topology=extend — IDD stays extended (no isolate / no primary)" + ); + } } thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens } @@ -997,17 +1006,22 @@ fn linger_ms() -> u64 { .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 { +/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's +/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy +/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD +/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the +/// physical(s) so the IDD is the sole composited desktop. +fn topology_action() -> crate::vdisplay::policy::Topology { 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 - ); + if crate::vdisplay::policy::prefs() + .configured_effective() + .is_some() + { + return crate::vdisplay::effective_topology(); + } + if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() { + Topology::Extend + } else { + Topology::Exclusive } - std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() } diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index 7b4ae93..2e6560f 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -18,11 +18,13 @@ use windows::Win32::Devices::Display::{ DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes, QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE, - DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, + DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, + DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME, QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION, SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG, }; +use windows::Win32::Foundation::POINTL; use windows::Win32::Graphics::Gdi::{ ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH, @@ -431,6 +433,86 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option Option { + let mut np = 0u32; + let mut nm = 0u32; + if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { + return None; + } + let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize]; + let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize]; + if QueryDisplayConfig( + QDC_ONLY_ACTIVE_PATHS, + &mut np, + paths.as_mut_ptr(), + &mut nm, + modes.as_mut_ptr(), + None, + ) + .is_err() + { + return None; + } + paths.truncate(np as usize); + modes.truncate(nm as usize); + let saved = (paths.clone(), modes.clone()); + + // The virtual output's source width, to shift the physicals past it. + let virt_width = paths.iter().find_map(|p| { + if p.targetInfo.id != keep_target_id { + return None; + } + let idx = p.sourceInfo.modeInfoIdx as usize; + let m = modes.get(idx)?; + (m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE) + .then(|| m.Anonymous.sourceMode.width as i32) + })?; + + // Reposition each active path's SOURCE once: the virtual to (0,0) (= primary), the rest shifted + // right by the virtual's width (kept active, no overlap). Dedup source-mode indices (a cloned + // group would share one). + let mut done = std::collections::HashSet::new(); + for p in paths.iter() { + let idx = p.sourceInfo.modeInfoIdx as usize; + if !done.insert(idx) { + continue; + } + let Some(m) = modes.get_mut(idx) else { + continue; + }; + if m.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE { + continue; + } + if p.targetInfo.id == keep_target_id { + m.Anonymous.sourceMode.position = POINTL { x: 0, y: 0 }; + } else { + m.Anonymous.sourceMode.position.x += virt_width; + } + } + + let rc = SetDisplayConfig( + Some(paths.as_slice()), + Some(modes.as_slice()), + SDC_APPLY + | SDC_USE_SUPPLIED_DISPLAY_CONFIG + | SDC_ALLOW_CHANGES + | SDC_FORCE_MODE_ENUMERATION, + ); + if rc == 0 { + tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); physical display(s) kept ACTIVE, shifted right by {virt_width}px"); + } else { + tracing::warn!("display primary (CCD): SetDisplayConfig failed rc={rc:#x} (virtual {keep_target_id} primary, physicals kept)"); + } + Some(saved) +} + /// Restore the topology saved by [`isolate_displays_ccd`] (teardown, before the virtual output is /// removed), re-activating the displays we deactivated. // pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper. From d23bd9b0cff7921a1c193a3742e70a77bbb7dad7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 09:27:07 +0000 Subject: [PATCH 10/40] fix(vdisplay/windows): DISPLAYCONFIG_PATH_SOURCE_INFO union field access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modeInfoIdx lives in the Anonymous union (windows-rs), not directly on sourceInfo — set_virtual_primary_ccd now reads .Anonymous.modeInfoIdx. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/windows/win_display.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index 2e6560f..351963c 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -469,7 +469,7 @@ pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option Option Date: Sun, 5 Jul 2026 09:36:53 +0000 Subject: [PATCH 11/40] diag(vdisplay/windows): log active paths in set_virtual_primary_ccd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary diagnostic — the physical monitor goes black in topology=primary despite rc=0; the SSH/session-0 view can't see the real interactive-session topology, so log the active paths the host actually operates on. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/windows/win_display.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index 351963c..c5e6fab 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -464,6 +464,32 @@ pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option Date: Sun, 5 Jul 2026 09:41:20 +0000 Subject: [PATCH 12/40] fix(vdisplay/windows): topology=primary force-extends to reactivate the physical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: on a headless box the IDD auto-activates as the SOLE display, so QueryDisplayConfig sees only the virtual — the physical is already deactivated before set_virtual_primary_ccd runs (no physical to keep). Force EXTEND first to reactivate every connected display alongside the virtual, then reposition to make the virtual primary, keeping the physical active. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/vdisplay/windows/manager.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index e60aa20..11ff0b0 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -648,6 +648,14 @@ impl VirtualDisplayManager { ccd_saved = unsafe { isolate_displays_ccd(added.target_id) }; } Topology::Primary => { + // The IDD auto-activates as the SOLE display on a headless box, so the + // physical (if present) is deactivated and QueryDisplayConfig sees only the + // virtual. Force EXTEND first to (re)activate every CONNECTED display + // alongside the virtual, THEN reposition to make the virtual primary — so the + // physical stays active. (The bring-up above only force-EXTENDs when the + // virtual FAILS to auto-resolve; here it resolved, so we do it explicitly.) + unsafe { force_extend_topology() }; + thread::sleep(Duration::from_millis(300)); ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) }; } Topology::Extend | Topology::Auto => { From 029d1134a9bb8a5e3e53fb3b0690ef10737c04fe Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 09:55:13 +0000 Subject: [PATCH 13/40] harden(vdisplay/windows): verify+retry exclusive isolation; pack primary layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exclusive (topology=exclusive) was fire-and-forget — a field-reported bug had a physical monitor STAY ACTIVE. isolate_displays_ccd now re-queries after each apply and RETRIES (up to 4x) until count_other_active()==0, never trusting rc alone; logs SOLE-active on success, an error if a display survives all attempts. Secure desktop correctness depends on the lock screen not landing on a stray panel. Primary: drop the temporary per-path diagnostic; pack the kept displays left-to- right from the virtual's right edge instead of blindly shifting each by virt_width (which left a dead gap when extend already placed them right). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/windows/win_display.rs | 164 +++++++++--------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index c5e6fab..55806ec 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -355,16 +355,10 @@ pub(crate) type SavedConfig = (Vec, Vec Option { +/// Query the current ACTIVE display config (paths + modes), truncated to the real counts. `None` on +/// API failure. Shared by [`isolate_displays_ccd`] (snapshot + per-attempt re-query) and +/// [`count_other_active`]. +unsafe fn query_active_config() -> Option { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { @@ -386,50 +380,77 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option Option { + let (paths, _) = query_active_config()?; + Some( + paths + .iter() + .filter(|p| { + p.targetInfo.id != keep_target_id && p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 + }) + .count() as u32, + ) +} + +/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices + +/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't +/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop / +/// lock screen lands on IT while our virtual output freezes. `QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS)` +/// sees every active path; we deactivate all of them EXCEPT the SudoVDA target's, leaving the virtual +/// display as the sole desktop so ALL content (incl. Winlogon) renders to it. Apollo isolates the same +/// way (CCD). Returns the original active config to restore on teardown. +// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper +// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies). +pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option { + // Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes. + let saved = query_active_config()?; + + // Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical + // monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the + // live topology each attempt and re-apply until ONLY the keep target is active. Secure-desktop + // correctness depends on this — the lock screen must not land on a stray panel while we stream. + for attempt in 1..=4u32 { + let (mut paths, mut modes) = query_active_config()?; + let mut others = 0u32; + for p in paths.iter_mut() { + if p.targetInfo.id == keep_target_id { + continue; + } + if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 { + p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive + others += 1; + } } - if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 { - p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive - others += 1; - } - } - if others == 0 { - // The virtual path shows active in the CCD database (from set_active_mode's legacy - // ChangeDisplaySettingsExW), but a legacy mode-set does NOT drive the IddCx adapter's - // EVT_IDD_CX_ADAPTER_COMMIT_MODES — and without COMMIT_MODES the OS never calls - // ASSIGN_SWAPCHAIN, so the driver never receives composed frames. Force an explicit CCD - // SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates. - // SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB - // already lists the path active. - let rc = SetDisplayConfig( - Some(paths.as_slice()), - Some(modes.as_slice()), - SDC_APPLY - | SDC_USE_SUPPLIED_DISPLAY_CONFIG - | SDC_ALLOW_CHANGES - | SDC_SAVE_TO_DATABASE - | SDC_FORCE_MODE_ENUMERATION, - ); - tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)"); - return Some(saved); - } - let rc = SetDisplayConfig( - Some(paths.as_slice()), - Some(modes.as_slice()), - SDC_APPLY + // Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does + // NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS + // never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION + // forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior — + // don't permanently rewrite the user's multi-display layout; the teardown restore handles it). + let mut flags = SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES - | SDC_FORCE_MODE_ENUMERATION, - ); - if rc == 0 { - tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop"); - } else { - tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))"); + | SDC_FORCE_MODE_ENUMERATION; + if others == 0 { + flags |= SDC_SAVE_TO_DATABASE; + } + let rc = SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags); + + // VERIFY the OUTCOME (rc alone lies — a "successful" apply can leave a panel active): re-query + // and confirm no non-keep display survived. Only then is the virtual truly the sole desktop. + let survivors = count_other_active(keep_target_id).unwrap_or(0); + if survivors == 0 { + tracing::info!("display isolate (CCD): target {keep_target_id} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})"); + return Some(saved); + } + tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying"); + std::thread::sleep(std::time::Duration::from_millis(250)); } + tracing::error!("display isolate (CCD): FAILED to isolate target {keep_target_id} after 4 attempts — a non-virtual display stayed active (the field-reported exclusive-mode bug)"); Some(saved) } @@ -464,33 +485,7 @@ pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option Option Option Option Date: Sun, 5 Jul 2026 10:21:28 +0000 Subject: [PATCH 14/40] =?UTF-8?q?feat(vdisplay):=20mode-conflict=20admissi?= =?UTF-8?q?on=20=E2=80=94=20separate/join/steal/reject=20(Stage=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode_conflict policy is now enforced at ADMISSION, before the punktfunk/1 Welcome, when a DIFFERENT client connects while another client's session is live: - separate (default, unconfigured → no change): each client its own display. - join: admit at the live display's mode (honest-downgrade — the Welcome carries it). - steal: signal the victim session(s)' stop flags, wait the release grace, serve. - reject: refuse the handshake with a busy reason (live mode + client label). New vdisplay/admission.rs: the pure decide() (unit-tested — same-client never conflicts, anonymous clients each distinct, join targets the oldest session) + a live-session registry (identity + mode + stop flag) sessions register in once up. Wired into punktfunk1 serve_session: admit() before validate_dimensions, register after the data plane binds. A same-client reconnect never conflicts. Validated on loopback (two probes, distinct identities, differing modes) across all four policies: separate→own mode, join→live mode, steal→victim interrupted, reject→handshake refused. Remaining Stage-4 surface (deferred): GameStream 503 path, Windows-specific defaults (separate→join map, silent-reconfigure→steal), reject reason delivered to the client as a typed message (currently host-side log + connection close). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/punktfunk1.rs | 57 ++++- crates/punktfunk-host/src/vdisplay.rs | 4 + .../punktfunk-host/src/vdisplay/admission.rs | 224 ++++++++++++++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 crates/punktfunk-host/src/vdisplay/admission.rs diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index ef9f1ff..368fb4d 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -652,7 +652,7 @@ async fn serve_session( let source = opts.source; let frames = opts.frames; let handshake = async { - let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; + let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; anyhow::ensure!( hello.abi_version == punktfunk_core::WIRE_VERSION, "wire version mismatch: client {} host {}", @@ -684,6 +684,45 @@ async fn serve_session( "video codec negotiated" ); + // Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's + // session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate` + // (default, no change), `join` (serve at the live mode — an honest downgrade the client + // renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake). + // A same-client reconnect never conflicts. THIS session registers in the live set once its + // data plane is up (below the handshake), so a later client can see + steal it. + { + use crate::vdisplay::admission::{admit, Admission}; + match admit(endpoint::peer_fingerprint(&conn)) { + Admission::Separate => {} + Admission::Join(m) => { + tracing::info!( + requested = + %format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz), + live = %format_args!("{}x{}@{}", m.0, m.1, m.2), + "mode-conflict: JOIN — admitting at the live display's mode" + ); + hello.mode.width = m.0; + hello.mode.height = m.1; + hello.mode.refresh_hz = m.2; + } + Admission::Steal(victims) => { + tracing::info!( + victims = victims.len(), + "mode-conflict: STEAL — preempting the live session(s)" + ); + for v in &victims { + v.store(true, Ordering::SeqCst); + } + // Give the victims the release grace to tear their display down before we acquire. + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + Admission::Reject(reason) => { + tracing::warn!("mode-conflict: REJECT — {reason}"); + anyhow::bail!("{reason}"); + } + } + } + crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height) .context("client-requested mode")?; @@ -1055,6 +1094,22 @@ async fn serve_session( }); } + // Register this now-live session for mode-conflict admission (Stage 4): carry its identity, the + // negotiated mode, and its stop flag so a LATER connecting client's admission can see it and + // (under `steal`) signal it. The guard removes the entry when this session ends. + let _live_guard = { + let id = endpoint::peer_fingerprint(&conn); + let label = id + .map(|fp| fp.iter().take(4).map(|b| format!("{b:02x}")).collect::()) + .unwrap_or_else(|| "client".to_string()); + crate::vdisplay::admission::register( + id, + (welcome.mode.width, welcome.mode.height, welcome.mode.refresh_hz), + stop.clone(), + label, + ) + }; + // Audio plane (virtual source only — synthetic runs are protocol tests): desktop Opus // → host→client QUIC datagrams, on its own native thread. Best-effort on every failure // (no PipeWire audio, spawn error): the session continues without audio — and a spawn diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index c34fb2d..d427f25 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -785,6 +785,10 @@ mod gamescope; #[allow(dead_code)] #[path = "vdisplay/identity.rs"] pub(crate) mod identity; +// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the +// live-session registry, wired into the punktfunk/1 handshake. +#[path = "vdisplay/admission.rs"] +pub(crate) mod admission; #[cfg(target_os = "linux")] #[path = "vdisplay/linux/kwin.rs"] mod kwin; diff --git a/crates/punktfunk-host/src/vdisplay/admission.rs b/crates/punktfunk-host/src/vdisplay/admission.rs new file mode 100644 index 0000000..ff9fc6a --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/admission.rs @@ -0,0 +1,224 @@ +//! Mode-conflict **admission** (design: `design/display-management.md` §5.3, Stage 4). When a +//! *different* client connects while another client's session is already live, the `mode_conflict` +//! policy decides what happens — BEFORE the Welcome / RTSP launch, so the client gets an honest answer +//! instead of a mid-build failure: +//! +//! * `separate` — proceed on a fresh display at the requested mode (today's Linux multi-view / the +//! default; no behavior change unconfigured). +//! * `join` — admit at the live display's mode (honest-downgrade: the Welcome carries the real mode). +//! * `steal` — signal the victim session(s)' stop flag(s), wait the release grace, then serve. +//! * `reject` — refuse with a typed handshake error naming the live mode + client. +//! +//! A **live-session registry** ([`register`]) lets the decision see the current sessions (identity + +//! mode + stop flag); each session registers once admitted and drops its [`LiveGuard`] on end. The +//! decision itself ([`decide`]) is pure over a session slice, so it is unit-tested exhaustively. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::vdisplay::policy::{self, ModeConflict}; + +/// A currently-live session, as admission sees it. +#[derive(Clone)] +pub struct LiveSession { + id: u64, + /// The owning client's cert fingerprint (`None` = anonymous / no client cert presented). + pub identity: Option<[u8; 32]>, + pub mode: (u32, u32, u32), + /// The session's stop flag — signaled to preempt it on `steal`. + pub stop: Arc, + /// Short client label for `reject` messages. + pub label: String, +} + +/// The admission outcome for a connecting session. +#[derive(Debug)] +pub enum Admission { + /// No conflict / `separate`: proceed on a fresh display at the requested mode. + Separate, + /// `join`: admit at this (live) mode — share the existing display (honest-downgrade). + Join((u32, u32, u32)), + /// `steal`: signal these victim stop flags, wait the release grace, then proceed at the requested mode. + Steal(Vec>), + /// `reject`: refuse with this reason (host-busy + live mode + client label). + Reject(String), +} + +fn table() -> &'static Mutex> { + static T: OnceLock>> = OnceLock::new(); + T.get_or_init(|| Mutex::new(Vec::new())) +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +/// Two identities are the same client iff both are present and equal. Anonymous (`None`) never +/// matches — we can't prove it's the same client, so two anonymous clients are treated as distinct +/// (each conflicts), which is the safe side for `steal`/`reject`. +fn same_client(a: Option<[u8; 32]>, b: Option<[u8; 32]>) -> bool { + matches!((a, b), (Some(x), Some(y)) if x == y) +} + +/// The mode-conflict decision, pure over the live-session slice (so it's unit-testable). A conflict is +/// a live session owned by a DIFFERENT client — a same-client reconnect adopts / reconfigures its own +/// display and never conflicts (so it always resolves to `Separate` here and preempts downstream). +pub fn decide( + conflict: ModeConflict, + req_identity: Option<[u8; 32]>, + live: &[LiveSession], +) -> Admission { + let others: Vec<&LiveSession> = live + .iter() + .filter(|s| !same_client(s.identity, req_identity)) + .collect(); + if others.is_empty() { + return Admission::Separate; // no other client is live → no conflict + } + match conflict { + ModeConflict::Separate => Admission::Separate, + // Join at the OLDEST other session's mode (the established "primary" the desktop is built on). + ModeConflict::Join => Admission::Join(others[0].mode), + ModeConflict::Steal => { + Admission::Steal(others.iter().map(|s| Arc::clone(&s.stop)).collect()) + } + ModeConflict::Reject => { + let v = others[0]; + Admission::Reject(format!( + "host busy: streaming {}x{}@{} to {}", + v.mode.0, v.mode.1, v.mode.2, v.label + )) + } + } +} + +/// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy +/// (default `Separate` when unconfigured — no behavior change) and [`decide`] against the live set. +pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { + let conflict = policy::prefs() + .configured_effective() + .map(|e| e.mode_conflict) + .unwrap_or(ModeConflict::Separate); + decide(conflict, req_identity, &table().lock().unwrap()) +} + +/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call +/// AFTER [`admit`] (so a session never conflicts with itself) and once the mode + stop flag are known. +pub fn register( + identity: Option<[u8; 32]>, + mode: (u32, u32, u32), + stop: Arc, + label: String, +) -> LiveGuard { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + table().lock().unwrap().push(LiveSession { + id, + identity, + mode, + stop, + label, + }); + LiveGuard { id } +} + +/// RAII handle: removes its live-session entry from the registry on drop (session end). +pub struct LiveGuard { + id: u64, +} + +impl Drop for LiveGuard { + fn drop(&mut self) { + table().lock().unwrap().retain(|s| s.id != self.id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sess(identity: Option, mode: (u32, u32, u32)) -> LiveSession { + LiveSession { + id: 0, + identity: identity.map(|n| { + let mut f = [0u8; 32]; + f[0] = n; + f + }), + mode, + stop: Arc::new(AtomicBool::new(false)), + label: "peer".into(), + } + } + fn fp(n: u8) -> Option<[u8; 32]> { + let mut f = [0u8; 32]; + f[0] = n; + Some(f) + } + + #[test] + fn no_live_session_is_always_separate() { + for c in [ + ModeConflict::Separate, + ModeConflict::Join, + ModeConflict::Steal, + ModeConflict::Reject, + ] { + assert!(matches!(decide(c, fp(1), &[]), Admission::Separate)); + } + } + + #[test] + fn same_client_never_conflicts() { + let live = [sess(Some(1), (2560, 1440, 60))]; + // Even under reject/steal, the SAME client (fp 1) reconnecting is not a conflict. + assert!(matches!( + decide(ModeConflict::Reject, fp(1), &live), + Admission::Separate + )); + assert!(matches!( + decide(ModeConflict::Steal, fp(1), &live), + Admission::Separate + )); + } + + #[test] + fn different_client_applies_policy() { + let live = [sess(Some(1), (2560, 1440, 60))]; + assert!(matches!( + decide(ModeConflict::Separate, fp(2), &live), + Admission::Separate + )); + assert!(matches!( + decide(ModeConflict::Join, fp(2), &live), + Admission::Join((2560, 1440, 60)) + )); + assert!(matches!( + decide(ModeConflict::Steal, fp(2), &live), + Admission::Steal(v) if v.len() == 1 + )); + assert!(matches!( + decide(ModeConflict::Reject, fp(2), &live), + Admission::Reject(r) if r.contains("2560x1440@60") + )); + } + + #[test] + fn two_anonymous_clients_conflict() { + // Anonymous (None) can't be proven same-client, so a second anon client DOES conflict. + let live = [sess(None, (1920, 1080, 60))]; + assert!(matches!( + decide(ModeConflict::Reject, None, &live), + Admission::Reject(_) + )); + } + + #[test] + fn join_targets_the_oldest_other_session() { + let live = [ + sess(Some(1), (3840, 2160, 60)), // oldest + sess(Some(2), (1280, 720, 120)), + ]; + assert!(matches!( + decide(ModeConflict::Join, fp(3), &live), + Admission::Join((3840, 2160, 60)) + )); + } +} From cfad0cf7ee13fd06f455498fc35a862f996e86b3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 10:34:49 +0000 Subject: [PATCH 15/40] =?UTF-8?q?feat(vdisplay):=20finish=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20typed=20reject,=20Windows=20join-default,=20GameStr?= =?UTF-8?q?eam=20503?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the mode-conflict admission surface deferred from the initial Stage 4: - REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to ' string, which the client reads from ApplicationClosed (validated on loopback: the probe logs 'closed by peer: host busy … (code 66)'). - Windows default: separate (incl. the unconfigured default) resolves to JOIN — the Windows native host admits a second client at the live mode instead of the old silent last-wins reconfigure of the shared monitor (release-note behavior fix; the reconfigure is now opt-in as steal). separate stays multi-view on Linux. - GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept [u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches — reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate → take over. Same-client re-launch is never a conflict. Native reject-reason loopback-validated; Windows join-default pending .173 rebuild; GameStream 503 pending a Moonlight client (can't drive /launch autonomously). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/gamestream/mod.rs | 5 ++ .../punktfunk-host/src/gamestream/nvhttp.rs | 66 +++++++++++++++++-- crates/punktfunk-host/src/mgmt.rs | 2 + crates/punktfunk-host/src/punktfunk1.rs | 10 +++ .../punktfunk-host/src/vdisplay/admission.rs | 26 +++++++- 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index c1ebf56..ba626fb 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -108,6 +108,11 @@ pub struct LaunchSession { /// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4). /// `None` if the address could not be captured (then RTSP falls back to launch-present only). pub peer_ip: Option, + /// SHA-256 cert fingerprint of the paired client that owns this session — mode-conflict admission + /// (Stage 4) compares it against a launching client to tell a same-client re-launch (always + /// allowed) from a DIFFERENT client (subject to the `mode_conflict` policy). `[u8; 32]` keeps + /// [`LaunchSession`] `Copy`; `None` when the peer cert couldn't be read. + pub owner_fp: Option<[u8; 32]>, } /// Shared control-plane state used as the axum app state. diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index 7c02d25..eb78dac 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -126,15 +126,70 @@ async fn h_launch( peer: Option>, addr: Option>, Query(q): Query>, -) -> impl IntoResponse { +) -> Response { if !peer_is_paired(&peer, &st) { tracing::warn!("launch rejected — client is not paired"); - return xml(error_xml()); + return xml(error_xml()).into_response(); } + let req_fp: Option<[u8; 32]> = match &peer { + Some(Extension(PeerCertFingerprint(Some(fp)))) => { + hex::decode(fp).ok().and_then(|v| <[u8; 32]>::try_from(v).ok()) + } + _ => None, + }; + + // Mode-conflict ADMISSION (Stage 4). GameStream is single-session (`st.launch`), so when a + // DIFFERENT paired client launches while a session is live, the `mode_conflict` policy governs: + // `reject` → 503 (Moonlight shows "host is busy"); `join` → serve at the live session's mode; + // `steal`/`separate` (GameStream can't do separate) / unconfigured → take over (today's last-wins). + // A same-client re-launch is never a conflict. + let mut forced_mode: Option<(u32, u32, u32)> = None; + { + let cur = st.launch.lock().unwrap(); + if let Some(s) = cur.as_ref() { + let different = match (&s.owner_fp, &req_fp) { + (Some(owner), Some(req)) => owner != req, + _ => true, // unknown owner or anonymous requester → treat as a different client + }; + if different { + use crate::vdisplay::policy::{self, ModeConflict}; + let conflict = policy::prefs() + .configured_effective() + .map(|e| e.mode_conflict) + .unwrap_or(ModeConflict::Separate); + match conflict { + ModeConflict::Reject => { + tracing::warn!( + "GameStream launch REJECTED — host busy streaming {}x{}@{} to another client", + s.width, s.height, s.fps + ); + return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response(); + } + ModeConflict::Join => { + forced_mode = Some((s.width, s.height, s.fps)); + tracing::info!( + "GameStream launch JOIN — admitting at the live session's mode {}x{}@{}", + s.width, s.height, s.fps + ); + } + ModeConflict::Steal | ModeConflict::Separate => tracing::info!( + "GameStream launch STEAL — a different client is taking over the live session" + ), + } + } + } + } + match launch(&st, &q) { Ok(mut session) => { // Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP. session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip()); + session.owner_fp = req_fp; + if let Some((w, h, f)) = forced_mode { + session.width = w; + session.height = h; + session.fps = f; + } *st.launch.lock().unwrap() = Some(session); tracing::info!( w = session.width, @@ -144,11 +199,11 @@ async fn h_launch( "launch — session created; RTSP at rtsp://{}:{RTSP_PORT}", st.host.local_ip ); - xml(session_url_xml(&st, "gamesession")) + xml(session_url_xml(&st, "gamesession")).into_response() } Err(e) => { tracing::warn!(error = %format!("{e:#}"), "launch failed"); - xml(error_xml()) + xml(error_xml()).into_response() } } } @@ -210,7 +265,8 @@ fn launch(_st: &AppState, q: &HashMap) -> Result height, fps, appid, - peer_ip: None, // set by `h_launch` from the verified HTTPS peer address + peer_ip: None, // set by `h_launch` from the verified HTTPS peer address + owner_fp: None, // set by `h_launch` from the verified HTTPS peer cert fingerprint }) } diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index b3ce773..f5cf7c1 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -2498,6 +2498,7 @@ mod tests { fps: 120, appid: 1, peer_ip: None, + owner_fp: None, }); state.streaming.store(true, Ordering::SeqCst); @@ -2624,6 +2625,7 @@ mod tests { fps: 60, appid: 1, peer_ip: None, + owner_fp: None, }); let del = axum::http::Request::delete("/api/v1/session") diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 368fb4d..8feba5a 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -341,6 +341,11 @@ pub(crate) async fn serve( /// connects and never finishes the handshake would otherwise wedge the host for everyone. const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal, +/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct +/// code lets a client tell "host busy" apart from a transport failure. +const REJECT_BUSY_CODE: u32 = 0x42; + /// Encoder bitrate (kbps) the host falls back to when the client expresses no preference /// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its /// link (e.g. after a speed test) requests an explicit rate instead. @@ -718,6 +723,11 @@ async fn serve_session( } Admission::Reject(reason) => { tracing::warn!("mode-conflict: REJECT — {reason}"); + // Deliver the reason to the client as a TYPED refusal: close the QUIC connection + // with the BUSY application code + the reason bytes, which the client reads from + // the `ApplicationClosed` error (so its UI can say "host is streaming X to ") + // instead of seeing a bare connection drop. Then end the handshake. + conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes()); anyhow::bail!("{reason}"); } } diff --git a/crates/punktfunk-host/src/vdisplay/admission.rs b/crates/punktfunk-host/src/vdisplay/admission.rs index ff9fc6a..42bfb7a 100644 --- a/crates/punktfunk-host/src/vdisplay/admission.rs +++ b/crates/punktfunk-host/src/vdisplay/admission.rs @@ -91,13 +91,33 @@ pub fn decide( } /// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy -/// (default `Separate` when unconfigured — no behavior change) and [`decide`] against the live set. +/// (default `Separate` when unconfigured) and [`decide`] against the live set. +/// +/// **Windows** can't create SEPARATE virtual displays until the multi-monitor stage (§6.6), so a +/// `separate` outcome — including the **unconfigured default** — resolves to `join` (admit at the live +/// mode) rather than the old silent last-wins reconfigure of the shared monitor. This is the deliberate +/// Windows default change (release-note behavior fix); `steal` remains the way to force the new mode. pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { - let conflict = policy::prefs() + #[allow(unused_mut)] + let mut conflict = policy::prefs() .configured_effective() .map(|e| e.mode_conflict) .unwrap_or(ModeConflict::Separate); - decide(conflict, req_identity, &table().lock().unwrap()) + let live = table().lock().unwrap(); + #[cfg(windows)] + if matches!(conflict, ModeConflict::Separate) { + if live + .iter() + .any(|s| !same_client(s.identity, req_identity)) + { + tracing::warn!( + "mode_conflict=separate is not yet supported on Windows (multi-monitor is §6.6) — \ + JOINing the live session's mode instead" + ); + } + conflict = ModeConflict::Join; + } + decide(conflict, req_identity, &live) } /// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call From 980939ed6be389d4f0c5bfe2ecc8f4634b584998 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 10:43:08 +0000 Subject: [PATCH 16/40] refactor(gamestream): extract + unit-test gamestream_admission (Stage 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the GameStream mode-conflict decision out of h_launch into a pure gamestream_admission(live, req_fp, policy) -> GsDecision so the 503/join/take-over logic is unit-tested (no live session / same-client → Serve; different client → Reject/Join/Serve per policy; anonymous requester treated as different) — the GameStream path can't be driven without a Moonlight client, so this covers the logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/gamestream/nvhttp.rs | 141 +++++++++++++----- 1 file changed, 105 insertions(+), 36 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index eb78dac..988709c 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -138,44 +138,32 @@ async fn h_launch( _ => None, }; - // Mode-conflict ADMISSION (Stage 4). GameStream is single-session (`st.launch`), so when a - // DIFFERENT paired client launches while a session is live, the `mode_conflict` policy governs: - // `reject` → 503 (Moonlight shows "host is busy"); `join` → serve at the live session's mode; - // `steal`/`separate` (GameStream can't do separate) / unconfigured → take over (today's last-wins). - // A same-client re-launch is never a conflict. + // Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT + // paired client launching while a session is live is governed by `mode_conflict` (see + // [`gamestream_admission`]). Snapshot the live owner + mode (Copy) so the lock isn't held over it. let mut forced_mode: Option<(u32, u32, u32)> = None; { - let cur = st.launch.lock().unwrap(); - if let Some(s) = cur.as_ref() { - let different = match (&s.owner_fp, &req_fp) { - (Some(owner), Some(req)) => owner != req, - _ => true, // unknown owner or anonymous requester → treat as a different client - }; - if different { - use crate::vdisplay::policy::{self, ModeConflict}; - let conflict = policy::prefs() - .configured_effective() - .map(|e| e.mode_conflict) - .unwrap_or(ModeConflict::Separate); - match conflict { - ModeConflict::Reject => { - tracing::warn!( - "GameStream launch REJECTED — host busy streaming {}x{}@{} to another client", - s.width, s.height, s.fps - ); - return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response(); - } - ModeConflict::Join => { - forced_mode = Some((s.width, s.height, s.fps)); - tracing::info!( - "GameStream launch JOIN — admitting at the live session's mode {}x{}@{}", - s.width, s.height, s.fps - ); - } - ModeConflict::Steal | ModeConflict::Separate => tracing::info!( - "GameStream launch STEAL — a different client is taking over the live session" - ), - } + let live = st + .launch + .lock() + .unwrap() + .as_ref() + .map(|s| (s.owner_fp, (s.width, s.height, s.fps))); + let conflict = crate::vdisplay::policy::prefs() + .configured_effective() + .map(|e| e.mode_conflict) + .unwrap_or(crate::vdisplay::policy::ModeConflict::Separate); + match gamestream_admission(live, req_fp, conflict) { + GsDecision::Serve => {} + GsDecision::Join((w, h, f)) => { + forced_mode = Some((w, h, f)); + tracing::info!("GameStream launch JOIN — admitting at the live session's mode {w}x{h}@{f}"); + } + GsDecision::Reject => { + tracing::warn!( + "GameStream launch REJECTED — host busy (mode_conflict=reject, session owned by another client)" + ); + return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response(); } } } @@ -279,6 +267,48 @@ fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> { Some((w, h, fps)) } +/// A live GameStream session's `(owner cert fingerprint, mode)` snapshot for [`gamestream_admission`]. +type LiveGs = (Option<[u8; 32]>, (u32, u32, u32)); + +/// The outcome of [`gamestream_admission`]. +enum GsDecision { + /// Proceed with the launch (no live session, a same-client re-launch, or `steal`/`separate` + /// taking over the single session). + Serve, + /// Serve at the live session's mode (`join` — honest-downgrade). + Join((u32, u32, u32)), + /// Refuse with a 503 (`reject`). + Reject, +} + +/// The GameStream single-session mode-conflict decision (Stage 4, pure so it's unit-tested). `live` +/// is the currently-live session's `(owner_fp, mode)` (`None` ⇒ no session live). No session or a +/// same-client re-launch ⇒ `Serve`; a DIFFERENT client launching applies `policy` — `reject` ⇒ +/// `Reject`, `join` ⇒ `Join` the live mode, `steal`/`separate` (GameStream has no separate) ⇒ `Serve` +/// (take over the one session). +fn gamestream_admission( + live: Option, + req_fp: Option<[u8; 32]>, + policy: crate::vdisplay::policy::ModeConflict, +) -> GsDecision { + use crate::vdisplay::policy::ModeConflict; + let Some((owner, mode)) = live else { + return GsDecision::Serve; + }; + let different = match (owner, req_fp) { + (Some(o), Some(r)) => o != r, + _ => true, // unknown owner or anonymous requester → treat as a different client + }; + if !different { + return GsDecision::Serve; + } + match policy { + ModeConflict::Reject => GsDecision::Reject, + ModeConflict::Join => GsDecision::Join(mode), + ModeConflict::Steal | ModeConflict::Separate => GsDecision::Serve, + } +} + fn session_url_xml(st: &AppState, tag: &str) -> String { format!( "\n\nrtsp://{}:{RTSP_PORT}\n<{tag}>1\n\n", @@ -405,4 +435,43 @@ mod tests { "a non-pinned cert stays rejected" ); } + + #[test] + fn gamestream_admission_policy_matrix() { + use crate::vdisplay::policy::ModeConflict; + let (a, b) = ([1u8; 32], [2u8; 32]); + let live = Some((Some(a), (2560, 1440, 120))); + // No live session → always Serve. + assert!(matches!( + gamestream_admission(None, Some(b), ModeConflict::Reject), + GsDecision::Serve + )); + // Same-client re-launch → Serve regardless of policy. + assert!(matches!( + gamestream_admission(live, Some(a), ModeConflict::Reject), + GsDecision::Serve + )); + // A DIFFERENT client applies the policy. + assert!(matches!( + gamestream_admission(live, Some(b), ModeConflict::Reject), + GsDecision::Reject + )); + assert!(matches!( + gamestream_admission(live, Some(b), ModeConflict::Join), + GsDecision::Join((2560, 1440, 120)) + )); + assert!(matches!( + gamestream_admission(live, Some(b), ModeConflict::Steal), + GsDecision::Serve + )); + assert!(matches!( + gamestream_admission(live, Some(b), ModeConflict::Separate), + GsDecision::Serve + )); + // Anonymous requester (no cert presented) is treated as a different client. + assert!(matches!( + gamestream_admission(live, None, ModeConflict::Reject), + GsDecision::Reject + )); + } } From 23446fa1770debfa720fab7643f4cbe5e86b7f90 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 11:32:52 +0000 Subject: [PATCH 17/40] fix(vdisplay): Windows admission default is reject, not join (single-capturer limit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two concurrent Windows sessions both drive the same pf-vdisplay monitor's single-capturer IDD-push channel (newest-delivery-wins), which freezes the live client and can wedge the driver (observed live: a concurrent-session test wedged .173 → Moonlight 'no video'; needed a reboot). True multi-session capture is §6.6/ Stage 7. So on Windows 'separate' (incl. the unconfigured default) now resolves to REJECT — a 2nd client gets a clean 503 and the live session is protected — instead of join (which would freeze it). join/steal stay explicit opt-ins; Linux keeps separate (real multi-view). Centralized as admission::effective_conflict(), shared by the native handshake + GameStream h_launch. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/gamestream/apps.rs | 37 ++++++++++++++++-- .../punktfunk-host/src/gamestream/nvhttp.rs | 7 ++-- .../punktfunk-host/src/vdisplay/admission.rs | 39 ++++++++----------- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index 9741c7f..b477ec2 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -170,18 +170,26 @@ pub fn appasset_bytes(appid: u32) -> Option<(Vec, String)> { /// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver /// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when /// true, Moonlight offers its per-app HDR toggle. +/// +/// The document is emitted **COMPACT — no whitespace between elements** — deliberately, to match +/// Sunshine/GFE. Moonlight-Android's `getAppListByReader` calls `appList.getLast()` on *every* XML +/// text node before it checks the current tag, and only fills `appList` on an `` start tag. A +/// pretty-print newline between `` and the first `` is a whitespace text node while +/// `appList` is still empty → `NoSuchElementException` → the Android app hard-crashes on host click. +/// (iOS/macOS parse via moonlight-common-c/expat and are unaffected; `serverinfo`/pairing use the +/// named-tag `getXmlString` scan, so their whitespace is harmless.) Keep this whitespace-free. pub fn applist_xml() -> String { let hdr = u8::from(crate::gamestream::host_hdr_capable()); let mut xml = - String::from("\n\n"); + String::from(""); for app in catalog() { xml.push_str(&format!( - "\n{hdr}\n{}\n{}\n\n", + "{hdr}{}{}", xml_escape(&app.title), app.id )); } - xml.push_str("\n"); + xml.push_str(""); xml } @@ -249,4 +257,27 @@ mod tests { assert!(xml.starts_with("").count(), xml.matches("").count()); } + + /// Regression: the applist MUST be whitespace-free between elements. Moonlight-Android's + /// `getAppListByReader` calls `appList.getLast()` on every text node before an `` has been + /// pushed, so a pretty-print newline between `` and the first `` crashes the app + /// (`NoSuchElementException`). Reproduced on 2 Android phones; iOS/macOS (moonlight-common-c) + /// were unaffected. Keep `applist_xml` compact like Sunshine/GFE. + #[test] + fn applist_xml_has_no_interelement_whitespace() { + let xml = applist_xml(); + // is immediately followed by the first — no whitespace text node while the + // parser's app list is still empty. + assert!( + xml.contains("status_code=\"200\">"), + "no whitespace between and the first : {xml}" + ); + // No pretty-print newlines anywhere in the element stream, and no whitespace-only text + // nodes between any adjacent tags. + assert!(!xml.contains('\n'), "applist must contain no newlines: {xml}"); + assert!( + !xml.contains("> <"), + "applist must contain no inter-element spaces: {xml}" + ); + } } diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index 988709c..be2c2ea 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -149,10 +149,9 @@ async fn h_launch( .unwrap() .as_ref() .map(|s| (s.owner_fp, (s.width, s.height, s.fps))); - let conflict = crate::vdisplay::policy::prefs() - .configured_effective() - .map(|e| e.mode_conflict) - .unwrap_or(crate::vdisplay::policy::ModeConflict::Separate); + // Same Windows default as the native path (separate → reject; see `effective_conflict`) so a + // 2nd Moonlight client gets a clean 503 rather than wedging the shared monitor's capture. + let conflict = crate::vdisplay::admission::effective_conflict(); match gamestream_admission(live, req_fp, conflict) { GsDecision::Serve => {} GsDecision::Join((w, h, f)) => { diff --git a/crates/punktfunk-host/src/vdisplay/admission.rs b/crates/punktfunk-host/src/vdisplay/admission.rs index 42bfb7a..1763793 100644 --- a/crates/punktfunk-host/src/vdisplay/admission.rs +++ b/crates/punktfunk-host/src/vdisplay/admission.rs @@ -90,34 +90,29 @@ pub fn decide( } } -/// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy -/// (default `Separate` when unconfigured) and [`decide`] against the live set. -/// -/// **Windows** can't create SEPARATE virtual displays until the multi-monitor stage (§6.6), so a -/// `separate` outcome — including the **unconfigured default** — resolves to `join` (admit at the live -/// mode) rather than the old silent last-wins reconfigure of the shared monitor. This is the deliberate -/// Windows default change (release-note behavior fix); `steal` remains the way to force the new mode. -pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { - #[allow(unused_mut)] - let mut conflict = policy::prefs() +/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when +/// unconfigured), with the **Windows default applied**. On Windows `separate` — including the +/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the +/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes +/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd +/// client gets a clean 503 and the live session is protected; `join`/`steal` stay as explicit opt-ins. +/// Linux keeps `separate` (real multi-view). Shared by the native + GameStream admission paths. +pub fn effective_conflict() -> ModeConflict { + let conflict = policy::prefs() .configured_effective() .map(|e| e.mode_conflict) .unwrap_or(ModeConflict::Separate); - let live = table().lock().unwrap(); #[cfg(windows)] if matches!(conflict, ModeConflict::Separate) { - if live - .iter() - .any(|s| !same_client(s.identity, req_identity)) - { - tracing::warn!( - "mode_conflict=separate is not yet supported on Windows (multi-monitor is §6.6) — \ - JOINing the live session's mode instead" - ); - } - conflict = ModeConflict::Join; + return ModeConflict::Reject; } - decide(conflict, req_identity, &live) + conflict +} + +/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`] +/// against the live set. +pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { + decide(effective_conflict(), req_identity, &table().lock().unwrap()) } /// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call From eddcd91f48574175c6c3c2479687a1e1f209d413 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 11:44:41 +0000 Subject: [PATCH 18/40] =?UTF-8?q?feat(vdisplay/kwin):=20group-aware=20excl?= =?UTF-8?q?usive=20=E2=80=94=20never=20disable=20a=20sibling=20output=20(S?= =?UTF-8?q?tage=205=20=C2=A76.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The critical latent bug Stage 3 introduced: per-slot output names mean a 2nd exclusive session's other_enabled_outputs() (which disabled 'everything not named Virtual-punktfunk') would black out the 1st session's Virtual-punktfunk- output. Fix: recognise the whole managed group by the shared Virtual-punktfunk prefix — exclusive now disables only NON-managed outputs (bootstrap/physical), never a group sibling. Plus first-slot-wins for the group primary (a_managed_output_is_primary): a later session joins as a secondary monitor of the shared desktop instead of stealing the shell off the first. Unit-tested. Start of Stage 5 (§6A many-clients-one-desktop). Remaining: Mutter/wlroots group-aware analogues, layout (auto-row/manual + /display/layout + console), per-group topology restore, gamescope groups. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/gamestream/stream.rs | 90 +++++++++++++-- .../punktfunk-host/src/vdisplay/linux/kwin.rs | 106 ++++++++++++++---- 2 files changed, 164 insertions(+), 32 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 6e0ff79..04c4731 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -399,6 +399,20 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec]) -> std::io::Result<()> { Ok(()) } +/// Pacing layout for one frame's `n` packets (`n >= 1`): `(chunk_size, steps)`. The chunk grows +/// with the frame so the number of paced bursts — each ending in a `thread::sleep` — never exceeds +/// `MAX_PACE_STEPS`. A fixed 16-packet chunk let the step count scale with bitrate (~38 for a +/// 4K/250Mbps frame's ~600 packets); the accumulated sub-ms sleep overshoot on the non-RT send +/// thread then blew the per-frame budget and backed the handoff queue up. Bounding the steps keeps +/// microburst shaping at low bitrate while making overshoot negligible and bitrate-independent. +fn pace_layout(n: usize) -> (usize, usize) { + const MIN_PACE_CHUNK: usize = 16; + const MAX_PACE_STEPS: usize = 12; + let chunk_sz = MIN_PACE_CHUNK.max(n.div_ceil(MAX_PACE_STEPS)); + let steps = n.div_ceil(chunk_sz); // ≤ MAX_PACE_STEPS + (chunk_sz, steps) +} + /// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in /// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval /// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode @@ -416,8 +430,14 @@ fn spawn_sender( // Transmit thread: above-normal, matching the native path's send thread (includes the // Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5). crate::punktfunk1::boost_thread_priority(false); - // Chunk pacing: 16 packets per burst, bursts spread across the send budget. - const PACE_CHUNK: usize = 16; + // Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number + // of bursts. A fixed 16-packet chunk made the burst count scale with bitrate (~38 for a + // 4K/250Mbps frame's ~600 packets), and each burst ends in a `thread::sleep`; on this + // non-RT send thread those sub-ms sleeps overshoot, and ~38 per frame blew the 12.5ms + // budget past the 16.67ms frame interval — backing the depth-2 handoff queue up and + // dropping ~half the frames ("send queue full"). Capping the step count keeps the + // microburst shaping (a real link drops line-rate bursts) while making per-frame sleep + // overshoot negligible and independent of bitrate. let budget = frame_interval.mul_f32(0.75); let mut rng = rand::thread_rng(); let mut sent: u64 = 0; @@ -436,17 +456,21 @@ fn spawn_sender( if n == 0 { continue; } - let per_chunk = budget.mul_f64((PACE_CHUNK as f64 / n as f64).min(1.0)); + // Chunk size + step count, bounded so a high-bitrate frame doesn't fan out into + // dozens of sleeps. Each step gets an equal slice of the budget (total pacing time + // == budget regardless of n). + let (chunk_sz, steps) = pace_layout(n); + let per_step = budget.mul_f64(1.0 / steps as f64); let start = Instant::now(); - for (i, chunk) in batch.chunks(PACE_CHUNK).enumerate() { + for (i, chunk) in batch.chunks(chunk_sz).enumerate() { if let Err(e) = sendmmsg_all(&sock, chunk) { tracing::info!(error = %e, sent, "video: client unreachable — stopping stream"); running.store(false, Ordering::SeqCst); return; } sent += chunk.len() as u64; - // Sleep toward the next chunk's deadline; skip sub-500µs sleeps (jitter). - let target = start + per_chunk.mul_f64((i + 1) as f64); + // Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter). + let target = start + per_step.mul_f64((i + 1) as f64); if let Some(ahead) = target.checked_duration_since(Instant::now()) { if ahead >= Duration::from_micros(500) { std::thread::sleep(ahead); @@ -582,6 +606,15 @@ fn stream_body( const MAX_REBUILDS: u32 = 5; let mut rebuilds: u32 = 0; + // Coalesce forced keyframes. Under loss Moonlight spams IDR/RFI requests; on an encoder without + // RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request + // stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session + // instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one + // we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC + // ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are. + let keyframe_coalesce = frame_interval * 2; + let mut last_keyframe: Option = None; + while running.load(Ordering::SeqCst) { let tick = Instant::now(); // Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is @@ -647,6 +680,7 @@ fn stream_body( .context("reopen encoder after rebuild")?; supports_rfi = enc.caps().supports_rfi; enc.request_keyframe(); + last_keyframe = Some(Instant::now()); next_frame = Instant::now(); tracing::info!("gamestream: source rebuilt — stream continues"); continue; @@ -656,17 +690,33 @@ fn stream_body( // Honor a client recovery request. Prefer reference-frame invalidation (the encoder // re-references an older still-valid frame — no costly IDR spike); if the encoder can't // invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe. + let mut want_keyframe = false; if let Some((first, last)) = rfi_range.lock().unwrap().take() { // Prefer reference-frame invalidation when the encoder supports it (no costly IDR - // spike); otherwise — or if the range is too old to invalidate — force a keyframe. + // spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe. if !(supports_rfi && enc.invalidate_ref_frames(first, last)) { - enc.request_keyframe(); + want_keyframe = true; } } - // An explicit IDR request (or a rangeless RFI) forces a keyframe so the client resyncs + // An explicit IDR request (or a rangeless RFI) asks for a keyframe so the client resyncs // immediately instead of waiting for the next GOP boundary. if force_idr.swap(false, Ordering::SeqCst) { - enc.request_keyframe(); + want_keyframe = true; + } + // Coalesce: emit at most one forced keyframe per in-flight window, so a burst of recovery + // requests during one loss event doesn't turn every frame into a full IDR (see above). + if want_keyframe { + let now = Instant::now(); + let emit = match last_keyframe { + Some(t) => now.duration_since(t) >= keyframe_coalesce, + None => true, + }; + if emit { + enc.request_keyframe(); + last_keyframe = Some(now); + } else { + tracing::debug!("video: keyframe request coalesced (IDR still in flight)"); + } } enc.submit(&frame).context("encoder submit")?; let t_enc = tick.elapsed(); @@ -891,4 +941,24 @@ mod tests { assert_eq!(got, 3 * PER_FRAME); assert!(running.load(Ordering::SeqCst), "no spurious client-gone"); } + + /// The pacing layout bounds the paced-burst (and thus sleep) count regardless of frame size, + /// while always covering every packet and keeping small frames on the 16-packet floor. Guards + /// the 4K/high-bitrate "send queue full" regression (a fixed 16-packet chunk fanned a ~600 + /// packet frame into ~38 sleeps, whose overshoot blew the per-frame send budget). + #[test] + fn pace_layout_bounds_step_count() { + for &n in &[1usize, 16, 146, 610, 1024, 5000, 50_000] { + let (chunk, steps) = pace_layout(n); + assert!(steps >= 1, "n={n}: at least one step"); + assert!(steps <= 12, "n={n}: step count {steps} exceeded the cap"); + assert!(chunk >= 16, "n={n}: chunk {chunk} below the 16-packet floor"); + assert!(chunk * steps >= n, "n={n}: {chunk}×{steps} must cover all packets"); + } + // Small frames stay on the floor: one 16-packet burst. + assert_eq!(pace_layout(1), (16, 1)); + assert_eq!(pace_layout(16), (16, 1)); + // A 4K/250Mbps frame (~600 packets) was ~38 bursts at a fixed 16 — now bounded. + assert!(pace_layout(610).1 <= 12); + } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index a9fb20a..f559b51 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -242,11 +242,17 @@ fn read_active_refresh(output: &str) -> Option { Some(hz.round() as u32) } -/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless -/// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j` -/// (same source as [`read_active_refresh`]). -fn other_enabled_outputs(name: &str) -> Vec { - let ours = format!("Virtual-{name}"); +/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-`, +/// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-`. Group membership (§6.1) is +/// recognised by this prefix, so we never have to thread the live set through the backend. +const MANAGED_PREFIX: &str = "Virtual-punktfunk"; + +/// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's +/// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable. +/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this +/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables +/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]). +fn other_enabled_outputs() -> Vec { let out = match std::process::Command::new("kscreen-doctor") .arg("-j") .output() @@ -262,19 +268,46 @@ fn other_enabled_outputs(name: &str) -> Vec { .and_then(|o| o.as_array()) .map(|outs| { outs.iter() - .filter(|o| { - o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false) - && o.get("name").and_then(|n| n.as_str()) != Some(ours.as_str()) - }) - .filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from)) + .filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false)) + .filter_map(|o| o.get("name").and_then(|n| n.as_str())) + .filter(|n| !n.starts_with(MANAGED_PREFIX)) + .map(String::from) .collect() }) .unwrap_or_default() } -/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so it becomes the sole -/// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the -/// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly +/// True if any managed group member (the [`MANAGED_PREFIX`] family) is ALREADY the KWin primary — +/// first-slot-wins support (§6.1) so a later exclusive session doesn't steal primary from the group's +/// first member. Best-effort: if kscreen reports no primary flag we treat it as "none" (the session +/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with +/// `"priority": 1`; older builds used a `"primary": true` bool — accept either. +fn a_managed_output_is_primary() -> bool { + let Ok(out) = std::process::Command::new("kscreen-doctor").arg("-j").output() else { + return false; + }; + let Ok(doc) = serde_json::from_slice::(&out.stdout) else { + return false; + }; + doc.get("outputs") + .and_then(|o| o.as_array()) + .map(|outs| { + outs.iter().any(|o| { + let managed = o + .get("name") + .and_then(|n| n.as_str()) + .is_some_and(|n| n.starts_with(MANAGED_PREFIX)); + let primary = o.get("primary").and_then(|p| p.as_bool()).unwrap_or(false) + || o.get("priority").and_then(|p| p.as_u64()) == Some(1); + managed && primary + }) + }) + .unwrap_or(false) +} + +/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes +/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for +/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly /// showing only the wallpaper) rather than failing the session. fn apply_virtual_primary(name: &str) -> Vec { let ours = format!("Virtual-{name}"); @@ -285,16 +318,21 @@ fn apply_virtual_primary(name: &str) -> Vec { .map(|s| s.success()) .unwrap_or(false) }; - // Make ours primary — KWin usually then re-homes the desktop and disables the bootstrap on its - // own. Let that settle, then belt-and-suspenders: disable anything still enabled besides ours so - // the streamed output is unambiguously the sole desktop regardless of KWin's implicit behaviour. - if !kscreen(&[format!("output.{ours}.primary")]) { - tracing::warn!( - "KWin: could not set the virtual output primary; client may see only the wallpaper" - ); + // First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd + // exclusive session joins as a secondary monitor of the shared desktop instead of stealing the + // shell off the 1st session's output. KWin usually then re-homes the desktop + disables the + // bootstrap on its own; the belt-and-suspenders disable below covers the rest. + if !a_managed_output_is_primary() { + if !kscreen(&[format!("output.{ours}.primary")]) { + tracing::warn!( + "KWin: could not set the virtual output primary; client may see only the wallpaper" + ); + } + std::thread::sleep(Duration::from_millis(200)); } - std::thread::sleep(Duration::from_millis(200)); - let others = other_enabled_outputs(name); + // Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so + // the group is unambiguously the desktop — never a sibling session's output (group-aware filter). + let others = other_enabled_outputs(); if !others.is_empty() { let args: Vec = others .iter() @@ -555,3 +593,27 @@ fn run( let _ = conn.flush(); Ok(()) } + +#[cfg(test)] +mod tests { + use super::MANAGED_PREFIX; + + /// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled, + /// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output + /// (the Stage-3 naming would otherwise make a 2nd exclusive session black out the 1st). + #[test] + fn exclusive_disables_only_non_managed() { + let enabled = [ + "Virtual-punktfunk", // base name (shared identity) + "Virtual-punktfunk-1", // client A's per-slot output + "Virtual-punktfunk-7", // client B's per-slot output + "eDP-1", // a physical panel + ]; + let to_disable: Vec<&str> = enabled + .iter() + .copied() + .filter(|n| !n.starts_with(MANAGED_PREFIX)) + .collect(); + assert_eq!(to_disable, vec!["eDP-1"]); + } +} From a5dc3134de84ca19783e868e2f8e7256b6095321 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 11:50:50 +0000 Subject: [PATCH 19/40] =?UTF-8?q?docs(display-management):=20handoff=20?= =?UTF-8?q?=E2=80=94=20mark=20Stages=200-4=20done,=20Stage=205=20started?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the design doc for handoff: top-of-doc status, a Status/handoff block in §11 (per-stage state, validation boxes, key decisions), and per-stage [DONE]/[STARTED] markers. Records the decisions that diverged from the plan as written — the Windows admission default is reject (single-capturer IDD-push), reject is typed (QUIC 0x42), Stage 5's group-aware exclusive fixes a Stage-3 latent bug — and what's left in Stage 5 (Mutter/wlroots analogues, layout, /display/layout, per-group restore). Co-Authored-By: Claude Opus 4.8 (1M context) --- design/display-management.md | 74 ++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/design/display-management.md b/design/display-management.md index b0d0540..06818f7 100644 --- a/design/display-management.md +++ b/design/display-management.md @@ -1,6 +1,9 @@ # Virtual-display management & lifecycle policy — design -> **Status:** PLANNED (nothing implemented). This doc designs a **policy layer on top of the +> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 STARTED** (branch +> `display-mgmt-stage0`, not yet merged). See the **Status — handoff** block under §11 for the +> per-stage state, the key decisions (notably the Windows `reject` default), and what's left. +> 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 @@ -649,14 +652,53 @@ display is one data-plane instance — multi-display never muxes into the core p 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). +validation needs the GPU back or one of the LAN hosts. -- **Stage 0 — policy + plumbing-lite.** `policy.rs` (schema/presets/persist/env-compat, fully +### Status — 2026-07-05 handoff + +Branch **`display-mgmt-stage0`** (NOT merged; merge when the whole feature is polished/complete). +On-glass validation boxes: **`.173`** (Windows, pf-vdisplay + a physical monitor), **`.21`** (CachyOS +GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `fedora:43` distrobox; +`.48` Fedora KDE is DOWN). Every commit is `cargo test/clippy/fmt`-green. + +- **Stages 0–4: DONE + on-glass validated.** 0 (policy surface + `/display/settings` + console card), + 1 (pure `lifecycle.rs` + `registry.rs` Linux keep-alive pool + ownership split via `DisplayLease` + + `/display/state`/`/display/release`), 2 (topology decoupling — distinct `extend`/`primary`/`exclusive` + via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot + output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`), + 4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies). +- **Stage 5: STARTED** — only the critical §6.1 **group-aware exclusive** fix for KWin has landed + (`kwin.rs` `MANAGED_PREFIX` + first-slot-wins), unit-tested but NOT yet driven by two concurrent + sessions on-glass. Everything else in Stage 5 is TODO. + +**Decisions / deltas from this plan as written — read before continuing:** +- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two + concurrent Windows sessions both drive one pf-vdisplay monitor's **single-capturer** IDD-push channel + (newest-delivery-wins) → the 2nd freezes + can WEDGE the 1st (observed live: it wedged `.173`, needed a + reboot — surfaced as Moonlight "no video"). True multi-session Windows capture is §6.6/Stage 7. So on + Windows `separate` (incl. the unconfigured default) resolves to `reject` — a 2nd client gets a clean + 503, the live session is protected; `join`/`steal` are explicit opt-ins. Centralised in + `admission::effective_conflict()`, shared by the native handshake + GameStream `h_launch`. +- **Reject IS typed:** punktfunk/1 closes the QUIC connection with app code `0x42` + the reason + `"host busy: streaming WxH@Hz to "`, which the client reads from `ApplicationClosed`. +- **Stage 5's group-aware exclusive fixes a bug Stage 3 introduced:** per-slot names meant a 2nd + `exclusive` session's disable-filter would black out the 1st session's `Virtual-punktfunk-` output. + Fixed on KWin by recognising the whole managed group via the shared `Virtual-punktfunk` prefix. +- **GameStream 503** is implemented (owner-fp on `LaunchSession`, `gamestream_admission()` unit-tested, + shares `effective_conflict()`) but NOT Moonlight-validated (can't drive `/launch` autonomously). + +**Deferred (need a display-attached box / a specific compositor / a real client):** the `primary` +physical-keep EFFECT on Linux + a Windows primary-only CCD variant; **wlroots `exclusive`**; the KWin +set-150 %-scaling ROUND-TRIP (SSH can't drive `kscreen-doctor` into the live session — the persist +mechanism itself is already proven); GameStream 503 on-glass; two-concurrent-session validation of the +Stage-5 group-aware exclusive. + +- **Stage 0 — policy + plumbing-lite. [DONE ✓]** `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 +- **Stage 1 — lifecycle core + Linux keep-alive. [DONE ✓]** `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 @@ -665,23 +707,31 @@ validation needs the GPU back or one of the LAN hosts (.248 GNOME / .48 Fedora K 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 +- **Stage 2 — topology decoupling. [DONE ✓]** 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 +- **Stage 3 — identity. [DONE ✓]** 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/ +- **Stage 4 — mode-conflict admission. [DONE ✓]** Decision function (`vdisplay/admission.rs`, + `decide`/`admit`/`effective_conflict`) wired into the punktfunk/1 handshake + GameStream `h_launch`, + the typed punktfunk/1 `busy` refusal (QUIC close `0x42` + reason), GameStream 503 path, `steal` + victim signaling reusing the stop-flag plumbing. **The Windows default is `reject`, NOT the + `join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer + IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503 + unit-tested, Moonlight-pending. +- **Stage 5 — §6A multi-client monitors. [STARTED]** 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. + **Done so far:** KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by + the `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group + primary, unit-tested. **TODO:** Mutter + wlroots group-aware analogues (Mutter is more involved — its + sole-monitor `ApplyMonitorsConfig` must include ALL group virtuals, not just its own); layout + auto-row + manual + `/display/layout` + console table; per-group topology restore (restore the + physical only when the group's LAST member drops); gamescope groups (single-output → decline extras). *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. From e0f15822ae852ff8f66dc0da9931c5cb7bb5cc1a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 12:28:46 +0000 Subject: [PATCH 20/40] =?UTF-8?q?feat(vdisplay):=20Stage=205=20layout=20fo?= =?UTF-8?q?undation=20=E2=80=94=20arrangement=20engine=20+=20/display/layo?= =?UTF-8?q?ut=20+=20group=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6A layout, riding the Stages 1-3 registry with no protocol change: - vdisplay/layout.rs: pure arrangement engine — auto-row (left-to-right in acquire order, top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members). Unit-tested. - Registry group model (Linux): group = backend (one desktop per compositor session). /display/state groups entries, orders by acquire (gen), and computes each member's position via the engine (pure `assemble_displays`, unit-tested). DisplayInfo carries group/display_index/position/identity_slot/topology. The backend reports its resolved slot via the new VirtualDisplay::last_identity_slot (KWin only), so the arrangement + state key on per-client identity. - Registry-driven position apply: new VirtualDisplay::apply_position(x,y) (default no-op; KWin drives kscreen-doctor). Right after create the registry computes the new display's position over its whole group (pure `position_for_new`, unit-tested) and applies it — one seam for BOTH deterministic auto-row AND manual placement. Guarded: the origin (0,0) is skipped, so a single-display / first-of-group session (and every non-KWin backend) issues no positioning — the historical single-display path is unchanged. On-glass-validation-pending. - PUT /api/v1/display/layout: persists the console's manual arrangement via the pure EffectivePolicy::with_manual_layout transform (locks current effective behavior into explicit Custom fields + sets a manual layout, so arranging is orthogonal to the other axes). OpenAPI regenerated. - /display/settings `enforced` now lists all five axes (keep_alive, topology, mode_conflict [Stage 4], identity [Stage 3], layout [Stage 5]) — was stale at keep_alive+topology; the console reads it to know which controls are live. Still Stage-5 TODO (design/display-management.md §11): Mutter/wlroots group-aware analogues, per-group topology restore, the web arrangement table, gamescope decline. cargo build/test/clippy/fmt green; OpenAPI in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/openapi.json | 112 +++++- crates/punktfunk-host/src/mgmt.rs | 88 ++++- crates/punktfunk-host/src/vdisplay.rs | 22 ++ crates/punktfunk-host/src/vdisplay/layout.rs | 142 ++++++++ .../punktfunk-host/src/vdisplay/linux/kwin.rs | 38 +- crates/punktfunk-host/src/vdisplay/policy.rs | 50 +++ .../punktfunk-host/src/vdisplay/registry.rs | 335 ++++++++++++++++-- design/display-management.md | 66 +++- 8 files changed, 804 insertions(+), 49 deletions(-) create mode 100644 crates/punktfunk-host/src/vdisplay/layout.rs diff --git a/api/openapi.json b/api/openapi.json index f41f8a9..24ed735 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -138,6 +138,58 @@ } } }, + "/api/v1/display/layout": { + "put": { + "tags": [ + "display" + ], + "summary": "Arrange virtual displays", + "description": "Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor\ngroup (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block\nand switched to manual mode; applied from the next connect (a live group re-applies on its next\nacquire). Locks in the current effective behavior as explicit fields, so arranging displays never\nsilently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.", + "operationId": "setDisplayLayout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplayLayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Layout stored; the new settings state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplaySettingsState" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Layout could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/display/release": { "post": { "tags": [ @@ -1775,7 +1827,12 @@ "backend", "mode", "state", - "sessions" + "sessions", + "group", + "display_index", + "x", + "y", + "topology" ], "properties": { "backend": { @@ -1789,6 +1846,12 @@ ], "description": "Short client label, when the owner tracks it." }, + "display_index": { + "type": "integer", + "format": "int32", + "description": "This display's ordinal within its group, in acquire order (0-based).", + "minimum": 0 + }, "expires_in_ms": { "type": [ "integer", @@ -1798,6 +1861,21 @@ "description": "Milliseconds until a lingering display is torn down (absent when active/pinned).", "minimum": 0 }, + "group": { + "type": "integer", + "format": "int32", + "description": "Display group (shared desktop) id — several displays with the same group form one desktop (§6A).", + "minimum": 0 + }, + "identity_slot": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).", + "minimum": 0 + }, "mode": { "type": "string", "description": "`WIDTHxHEIGHT@HZ`." @@ -1817,6 +1895,20 @@ "state": { "type": "string", "description": "`active` | `lingering` | `pinned`." + }, + "topology": { + "type": "string", + "description": "Effective topology for this display's group (`extend` | `primary` | `exclusive`)." + }, + "x": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2)." + }, + "y": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left `y`." } } }, @@ -2128,6 +2220,22 @@ } } }, + "DisplayLayoutRequest": { + "type": "object", + "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).", + "properties": { + "positions": { + "type": "object", + "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits.", + "additionalProperties": { + "$ref": "#/components/schemas/Position" + }, + "propertyNames": { + "type": "string" + } + } + } + }, "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`].", @@ -2188,7 +2296,7 @@ "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." + "description": "Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `/display/state`)." }, "presets": { "type": "array", diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index f5cf7c1..8d8db35 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -160,6 +160,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(set_display_settings)) .routes(routes!(get_display_state)) .routes(routes!(release_display)) + .routes(routes!(set_display_layout)) .routes(routes!(get_status)) .routes(routes!(get_local_summary)) .routes(routes!(list_paired_clients)) @@ -988,9 +989,10 @@ struct DisplaySettingsState { 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. + /// Option names this build enforces right now. All five axes are now acted on (keep_alive + + /// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console + /// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout + /// position apply being KWin-only, is reported per display in `/display/state`). enforced: Vec, } @@ -1031,7 +1033,13 @@ fn display_settings_state() -> DisplaySettingsState { settings, configured, presets, - enforced: vec!["keep_alive".into(), "topology".into()], + enforced: vec![ + "keep_alive".into(), + "topology".into(), + "mode_conflict".into(), + "identity".into(), + "layout".into(), + ], } } @@ -1114,6 +1122,18 @@ struct ApiDisplayInfo { sessions: u32, /// Short client label, when the owner tracks it. client: Option, + /// Display group (shared desktop) id — several displays with the same group form one desktop (§6A). + group: u32, + /// This display's ordinal within its group, in acquire order (0-based). + display_index: u32, + /// Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2). + x: i32, + /// Desktop-space top-left `y`. + y: i32, + /// Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous). + identity_slot: Option, + /// Effective topology for this display's group (`extend` | `primary` | `exclusive`). + topology: String, } /// The host's managed virtual displays right now. @@ -1166,6 +1186,12 @@ async fn get_display_state() -> Json { expires_in_ms: d.expires_in_ms, sessions: d.sessions, client: d.client, + group: d.group, + display_index: d.display_index, + x: d.position.0, + y: d.position.1, + identity_slot: d.identity_slot, + topology: d.topology, }) .collect(), }) @@ -1195,6 +1221,53 @@ async fn release_display( Json(ReleaseDisplayResult { released }) } +/// Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot +/// id as a string (the same id `/display/state` reports as `identity_slot`). +#[derive(Deserialize, ToSchema)] +struct DisplayLayoutRequest { + /// `{"": {"x": …, "y": …}}` — where each arranged display's top-left sits. + #[serde(default)] + positions: std::collections::BTreeMap, +} + +/// Arrange virtual displays +/// +/// Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor +/// group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block +/// and switched to manual mode; applied from the next connect (a live group re-applies on its next +/// acquire). Locks in the current effective behavior as explicit fields, so arranging displays never +/// silently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2. +#[utoipa::path( + put, + path = "/display/layout", + tag = "display", + operation_id = "setDisplayLayout", + request_body = DisplayLayoutRequest, + responses( + (status = OK, description = "Layout stored; the new settings state", body = DisplaySettingsState), + (status = INTERNAL_SERVER_ERROR, description = "Layout could not be persisted", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn set_display_layout(ApiJson(req): ApiJson) -> Response { + let store = crate::vdisplay::policy::prefs(); + // Lock the current effective behavior into explicit fields + set the manual arrangement (pure + // transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy + // axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.) + let policy = store.get().effective().with_manual_layout(req.positions); + if let Err(e) = store.set(policy) { + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("persist display layout: {e:#}"), + ); + } + tracing::info!( + positions = display_settings_state().settings.layout.positions.len(), + "management API: display layout updated" + ); + Json(display_settings_state()).into_response() +} + /// Live host status #[utoipa::path( get, @@ -2740,7 +2813,12 @@ mod tests { .iter() .filter_map(|v| v.as_str()) .collect(); - assert!(enforced.contains(&"keep_alive") && enforced.contains(&"topology")); + // All five axes are enforced now (Stages 0-5). + assert!(enforced.contains(&"keep_alive")); + assert!(enforced.contains(&"topology")); + assert!(enforced.contains(&"mode_conflict")); + assert!(enforced.contains(&"identity")); + assert!(enforced.contains(&"layout")); // `gaming-rig` expands to keep_alive: forever → rejected at Stage 0 (before any write). let put = axum::http::Request::put("/api/v1/display/settings") diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index d427f25..9884d60 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -64,6 +64,23 @@ pub trait VirtualDisplay: Send { /// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual /// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity. fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {} + /// The stable identity slot the backend resolved for the most recent [`create`](Self::create) — + /// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The + /// registry reads it right after `create` to key the display's group **arrangement** (manual + /// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend + /// with no per-client identity (Mutter/wlroots/gamescope) always auto-rows. Only KWin (per-slot + /// output naming) reports a real slot on Linux. + fn last_identity_slot(&self) -> Option { + None + } + /// Place the most-recently-[created](Self::create) output at `(x, y)` in the desktop coordinate + /// space (design `display-management.md` §6.2 — layout). The registry, which owns the display + /// **group**, computes the position from the whole group (auto-row or the console's manual + /// arrangement) and calls this right after `create`. Default no-op: only backends that can position + /// an output (KWin) implement it; the registry never calls it for the desktop origin `(0, 0)`, so a + /// single-display / first-of-group session issues no positioning at all. Best-effort — a failure + /// leaves the compositor's default placement. + fn apply_position(&mut self, _x: i32, _y: i32) {} } /// Compositors punktfunk knows how to drive (plan §6). @@ -729,6 +746,11 @@ pub(crate) mod lifecycle; #[path = "vdisplay/registry.rs"] pub(crate) mod registry; +// The pure display-arrangement engine (auto-row / manual → per-member positions), platform-neutral +// and unit-tested; the registry (state readout) and the KWin position apply consume it. +#[path = "vdisplay/layout.rs"] +pub(crate) mod layout; + /// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto` /// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test /// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected diff --git a/crates/punktfunk-host/src/vdisplay/layout.rs b/crates/punktfunk-host/src/vdisplay/layout.rs new file mode 100644 index 0000000..55f8b74 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/layout.rs @@ -0,0 +1,142 @@ +//! Pure display-**arrangement** engine (design: `design/display-management.md` §6.2). Given a +//! group's members (in acquire order) and the `layout` policy, compute each member's top-left +//! origin in the desktop coordinate space. No I/O, no OS types — the registry (for the +//! `/display/state` readout) and the per-backend position apply both consume it, so the auto-row / +//! manual math is defined and tested in exactly one place (the `pick_gamescope_mode` / `wiring_plan` +//! discipline). +//! +//! * **auto-row** — left-to-right in acquire order, top-aligned: member *i* sits at +//! `x = Σ widths[0..i]`, `y = 0`. This is what compositors mostly do by default, made +//! deterministic. +//! * **manual** — per-identity-slot offsets from [`Layout::positions`] (console-arranged): a member +//! whose stable identity slot has a stored position sits there; a member with no pin (no stored +//! position, or a shared/anonymous identity that has no slot) falls back to its auto-row origin, so +//! a half-arranged group never collapses everything onto the origin. +//! +//! Group membership + acquire order live in the registry ([`super::registry`]); this file only turns +//! that ordered member list into positions. + +use super::policy::{Layout, LayoutMode}; + +/// One display in a group, as the arranger sees it (given in acquire order). +#[derive(Clone, Copy, Debug)] +pub struct Member { + /// Stable per-client identity slot — the manual-layout key. `None` for a shared/anonymous + /// identity (no per-client slot), which can't carry a manual pin and therefore always auto-rows. + pub identity_slot: Option, + /// Pixel width, for auto-row `x` accumulation. Clamped at 0 (a bogus negative never shifts a + /// sibling left). + pub width: i32, +} + +/// A member's resolved desktop-space top-left origin. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Placement { + pub x: i32, + pub y: i32, +} + +/// The auto-row origin of member `i`: the summed width of every prior member, top-aligned. +fn auto_row_x(members: &[Member], i: usize) -> i32 { + members[..i].iter().map(|m| m.width.max(0)).sum() +} + +/// Arrange `members` (in acquire order) per `layout`, returning one [`Placement`] per member in the +/// same order. Pure — the single source of truth for auto-row / manual placement, shared by the +/// state readout and (KWin) the per-backend position apply. +pub fn arrange(members: &[Member], layout: &Layout) -> Vec { + members + .iter() + .enumerate() + .map(|(i, m)| { + let auto = Placement { + x: auto_row_x(members, i), + y: 0, + }; + match layout.mode { + LayoutMode::AutoRow => auto, + // A pinned member sits at its stored offset; an unpinned one falls back to auto-row. + LayoutMode::Manual => m + .identity_slot + .and_then(|slot| layout.positions.get(&slot.to_string())) + .map(|p| Placement { x: p.x, y: p.y }) + .unwrap_or(auto), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vdisplay::policy::Position; + use std::collections::BTreeMap; + + fn m(slot: Option, width: i32) -> Member { + Member { + identity_slot: slot, + width, + } + } + + fn manual(pairs: &[(&str, i32, i32)]) -> Layout { + let mut positions = BTreeMap::new(); + for (k, x, y) in pairs { + positions.insert(k.to_string(), Position { x: *x, y: *y }); + } + Layout { + mode: LayoutMode::Manual, + positions, + } + } + + #[test] + fn auto_row_accumulates_widths_top_aligned() { + let members = [m(Some(1), 2560), m(Some(2), 1920), m(None, 1280)]; + let out = arrange(&members, &Layout::default()); // default = AutoRow + assert_eq!( + out, + vec![ + Placement { x: 0, y: 0 }, + Placement { x: 2560, y: 0 }, + Placement { x: 4480, y: 0 }, + ] + ); + } + + #[test] + fn manual_honors_pins_by_identity_slot() { + let members = [m(Some(1), 2560), m(Some(7), 1920)]; + // Client 7 arranged to the LEFT of client 1 (crossing order reversed vs auto-row). + let layout = manual(&[("1", 1920, 0), ("7", 0, 0)]); + let out = arrange(&members, &layout); + assert_eq!(out[0], Placement { x: 1920, y: 0 }); + assert_eq!(out[1], Placement { x: 0, y: 0 }); + } + + #[test] + fn manual_unpinned_and_slotless_fall_back_to_auto_row() { + let members = [m(Some(1), 2560), m(Some(9), 1920), m(None, 1280)]; + // Only slot 1 is pinned; slot 9 has no stored pin; the third has no slot at all. + let layout = manual(&[("1", 100, 50)]); + let out = arrange(&members, &layout); + assert_eq!(out[0], Placement { x: 100, y: 50 }, "pinned"); + assert_eq!(out[1], Placement { x: 2560, y: 0 }, "unpinned → auto-row"); + assert_eq!(out[2], Placement { x: 4480, y: 0 }, "slotless → auto-row"); + } + + #[test] + fn empty_group_is_empty() { + assert!(arrange(&[], &Layout::default()).is_empty()); + assert!(arrange(&[], &manual(&[("1", 0, 0)])).is_empty()); + } + + #[test] + fn negative_width_never_shifts_siblings_left() { + let members = [m(Some(1), -100), m(Some(2), 1920)]; + let out = arrange(&members, &Layout::default()); + let origin = Placement { x: 0, y: 0 }; + assert_eq!(out[0], origin); + assert_eq!(out[1], origin, "clamped width contributes 0"); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index f559b51..86a03cf 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -75,6 +75,13 @@ const MAX_VERSION: u32 = 5; #[derive(Default)] pub struct KwinDisplay { client_fp: Option<[u8; 32]>, + /// The identity slot the last [`create`](VirtualDisplay::create) resolved (the per-client id, or + /// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key + /// the group arrangement + `/display/state` slot to the same id this backend named the output with. + last_slot: Option, + /// The base output name the last `create` used (`punktfunk` / `punktfunk-`) — so + /// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-`. + last_name: Option, } impl KwinDisplay { @@ -92,20 +99,45 @@ impl VirtualDisplay for KwinDisplay { self.client_fp = fingerprint; } + fn last_identity_slot(&self) -> Option { + self.last_slot + } + + fn apply_position(&mut self, x: i32, y: i32) { + let Some(name) = self.last_name.clone() else { + return; + }; + let output = format!("Virtual-{name}"); + // kscreen-doctor position syntax: `output..position.,`. + let ok = std::process::Command::new("kscreen-doctor") + .arg(format!("output.{output}.position.{x},{y}")) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + tracing::info!(output, x, y, "KWin: placed output in the desktop layout"); + } else { + tracing::warn!(output, x, y, "KWin: output position apply failed"); + } + } + fn create(&mut self, mode: Mode) -> Result { // Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id → // `punktfunk-` (KWin exposes `Virtual-punktfunk-`, whose per-output config KWin // persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux // defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND // it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`. - let name = match crate::vdisplay::identity::resolve_slot( + let slot = crate::vdisplay::identity::resolve_slot( self.client_fp, (mode.width, mode.height), crate::vdisplay::policy::Identity::Shared, - ) { + ); + self.last_slot = slot; // reported to the registry for the group arrangement + state slot + let name = match slot { Some(id) => format!("{VOUT_NAME}-{id}"), None => VOUT_NAME.to_string(), }; + self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout) let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); @@ -149,6 +181,8 @@ impl VirtualDisplay for KwinDisplay { } Topology::Extend | Topology::Auto => Vec::new(), }; + // Layout position (§6.2) is applied by the registry via `apply_position` right after create + // (it owns the display group, so it computes auto-row / manual placement over the whole group). Ok(VirtualOutput { node_id, remote_fd: None, diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs index 1987037..18b9df5 100644 --- a/crates/punktfunk-host/src/vdisplay/policy.rs +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -273,6 +273,29 @@ impl DisplayPolicy { } } +impl EffectivePolicy { + /// Build a persistable `Custom` [`DisplayPolicy`] that keeps THIS effective behavior but replaces + /// the arrangement with a **manual** layout at `positions` — the `/display/layout` endpoint's + /// transform, factored out pure so arranging displays stays orthogonal to the other axes and is + /// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new + /// layout — rule; a named preset would ignore them.) + pub fn with_manual_layout(&self, positions: BTreeMap) -> DisplayPolicy { + DisplayPolicy { + version: 1, + preset: Preset::Custom, + keep_alive: self.keep_alive, + topology: self.topology, + mode_conflict: self.mode_conflict, + identity: self.identity, + layout: Layout { + mode: LayoutMode::Manual, + positions, + }, + max_displays: self.max_displays, + } + } +} + /// 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 { @@ -526,6 +549,33 @@ mod tests { assert_eq!(p.max_displays, 16); } + #[test] + fn with_manual_layout_preserves_behavior_and_sets_positions() { + // Start from a preset's effective behavior (workstation: 5-min linger, exclusive, per-client). + let eff = DisplayPolicy { + preset: Preset::Workstation, + ..DisplayPolicy::default() + } + .effective(); + let mut positions = BTreeMap::new(); + positions.insert("1".to_string(), Position { x: 0, y: 0 }); + positions.insert("7".to_string(), Position { x: 2560, y: 0 }); + let p = eff.with_manual_layout(positions); + // Preset drops to Custom so the explicit fields (incl. the layout) rule… + assert_eq!(p.preset, Preset::Custom); + // …every other behavior axis is preserved verbatim… + assert_eq!(p.keep_alive, eff.keep_alive); + assert_eq!(p.topology, eff.topology); + assert_eq!(p.mode_conflict, eff.mode_conflict); + assert_eq!(p.identity, eff.identity); + assert_eq!(p.max_displays, eff.max_displays); + // …and the arrangement is the manual layout we asked for, surviving the effective round-trip. + let e2 = p.effective(); + assert_eq!(e2.layout.mode, LayoutMode::Manual); + let want = Position { x: 2560, y: 0 }; + assert_eq!(e2.layout.positions.get("7"), Some(&want)); + } + #[test] fn partial_json_fills_defaults() { // A hand-written file with only a couple of fields loads, the rest defaulting. diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 977b89d..94c975e 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -40,6 +40,19 @@ pub struct DisplayInfo { pub sessions: u32, /// Short client label (cert-fp prefix / peer), when the owner tracks it. pub client: Option, + /// Display **group** (shared desktop) id (design §6.1): Linux gives every backend session one + /// group; Windows is single-group (`1`). + pub group: u32, + /// This display's ordinal within its group, in acquire order (0-based) — the §6A "which monitor". + pub display_index: u32, + /// Desktop-space top-left origin `(x, y)` (design §6.2): auto-row, or the console's manual + /// arrangement when configured. + pub position: (i32, i32), + /// The stable per-client identity slot keying this display's persistent config + manual layout + /// (§5.4); `None` for a shared/anonymous identity. + pub identity_slot: Option, + /// The effective topology for this display's group (`"extend"` | `"primary"` | `"exclusive"`). + pub topology: String, } /// The live display set for the mgmt `/display/state` endpoint. @@ -48,6 +61,19 @@ pub struct Snapshot { pub displays: Vec, } +/// The effective display topology as a lowercase string for the snapshot (`effective_topology` +/// resolves `Auto` away; the arm is defensive). +fn topology_str() -> String { + use super::policy::Topology; + match super::effective_topology() { + Topology::Extend => "extend", + Topology::Primary => "primary", + Topology::Exclusive => "exclusive", + Topology::Auto => "auto", + } + .to_string() +} + /// Acquire a virtual display for a session: reuse a kept (lingering/pinned) display of the same /// backend + mode if one exists, else create a fresh one. Returns a [`VirtualOutput`](super::VirtualOutput) /// the capturer consumes as before — but its `keepalive` is a registry lease, so the *display* @@ -74,6 +100,9 @@ pub fn acquire( pub fn snapshot() -> Snapshot { #[cfg(target_os = "windows")] { + // Windows is single-monitor at this stage (§6.6 multi-monitor is Stage 7): one group, index 0, + // origin. Its per-client identity lives in the driver (EDID serial / ConnectorIndex), not + // surfaced here yet. let displays = super::manager::snapshot() .map(|i| DisplayInfo { slot: i.gen, @@ -83,6 +112,11 @@ pub fn snapshot() -> Snapshot { expires_in_ms: i.expires_in_ms, sessions: i.sessions, client: None, + group: 1, + display_index: 0, + position: (0, 0), + identity_slot: None, + topology: topology_str(), }) .into_iter() .collect(); @@ -137,7 +171,7 @@ mod linux { use super::DisplayInfo; use crate::vdisplay::lifecycle::{self, Release}; - use crate::vdisplay::policy::{self, Linger}; + use crate::vdisplay::policy::{self, Layout, Linger}; use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput}; /// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the @@ -152,6 +186,10 @@ mod linux { preferred_mode: Option<(u32, u32, u32)>, mode: Mode, backend: &'static str, + /// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for + /// shared/anonymous or a backend with no per-client identity) — keys the group arrangement + + /// the `/display/state` slot. Captured at create; kept across a keep-alive reuse. + identity_slot: Option, /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease /// — its entry was reused + re-stamped — is a no-op). gen: u64, @@ -273,6 +311,9 @@ mod linux { // Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads). let real = vd.create(mode)?; + // The identity slot the backend just resolved (KWin per-slot naming; `None` elsewhere) — keys + // the group arrangement (manual per-slot positions) + the state slot. + let identity_slot = vd.last_identity_slot(); // wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the // portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The @@ -297,9 +338,49 @@ mod linux { preferred_mode, mode, backend, + identity_slot, gen, }; - r.entries.lock().unwrap().push(entry); + + // Compute this new display's position in its group (design §6.2) BEFORE pushing, then push + // under the same lock: the group is the same-backend entries; the new one appends last + // (rightmost under auto-row). `position_for_new` is pure; the lock is held only across it + // (I/O-free) — the backend apply is below, outside the lock. + let position = { + use crate::vdisplay::layout::Member; + let layout_policy = policy::prefs() + .configured_effective() + .map(|e| e.layout) + .unwrap_or_default(); + let mut es = r.entries.lock().unwrap(); + let existing: Vec<(u64, Member)> = es + .iter() + .filter(|e| e.backend == backend) + .map(|e| { + ( + e.gen, + Member { + identity_slot: e.identity_slot, + width: e.mode.width as i32, + }, + ) + }) + .collect(); + let new_member = Member { + identity_slot, + width: mode.width as i32, + }; + let pos = position_for_new(existing, new_member, &layout_policy); + es.push(entry); + pos + }; + // Place the new output (design §6.2), best-effort, OUTSIDE the lock (kscreen blocks). Skip the + // desktop origin `(0, 0)` — it's the compositor default, so a single-display / first-of-group + // session (and every non-KWin backend, which no-ops `apply_position`) issues no positioning at + // all: the historical single-display path is untouched. *On-glass-validation-pending.* + if (position.x, position.y) != (0, 0) { + vd.apply_position(position.x, position.y); + } Ok(output_for(node_id, preferred_mode, gen)) } @@ -343,38 +424,135 @@ mod linux { } } + /// One live/kept display, flattened out of the pool under the lock — so the group + arrangement + /// math (which calls the layout engine) runs OUTSIDE the lock. + struct Row { + gen: u64, + backend: &'static str, + mode: Mode, + identity_slot: Option, + state: &'static str, + expires_in_ms: Option, + sessions: u32, + } + pub(super) fn snapshot() -> Vec { let Some(r) = REG.get() else { return Vec::new(); }; let now = Instant::now(); - r.entries - .lock() - .unwrap() - .iter() - .filter_map(|e| { - let (state, expires_in_ms, sessions) = match e.life { - lifecycle::State::Active { refs } => ("active", None, refs), - lifecycle::State::Lingering { until } => ( - "lingering", - Some(until.saturating_duration_since(now).as_millis() as u64), - 0, - ), - lifecycle::State::Pinned => ("pinned", None, 0), - // Idle entries are never stored (removed on teardown). - lifecycle::State::Idle => return None, - }; - Some(DisplayInfo { - slot: e.gen, - backend: e.backend.to_string(), - mode: (e.mode.width, e.mode.height, e.mode.refresh_hz), - state: state.to_string(), - expires_in_ms, - sessions, - client: None, + + // Flatten the live/kept entries under the lock (skip Idle — never stored anyway). + let rows: Vec = { + let es = r.entries.lock().unwrap(); + es.iter() + .filter_map(|e| { + let (state, expires_in_ms, sessions) = match e.life { + lifecycle::State::Active { refs } => ("active", None, refs), + lifecycle::State::Lingering { until } => ( + "lingering", + Some(until.saturating_duration_since(now).as_millis() as u64), + 0, + ), + lifecycle::State::Pinned => ("pinned", None, 0), + lifecycle::State::Idle => return None, + }; + Some(Row { + gen: e.gen, + backend: e.backend, + mode: e.mode, + identity_slot: e.identity_slot, + state, + expires_in_ms, + sessions, + }) }) - }) - .collect() + .collect() + }; + + let topology = super::topology_str(); + // The arrangement policy: the console's manual layout when configured, else auto-row. + let layout_policy: Layout = policy::prefs() + .configured_effective() + .map(|e| e.layout) + .unwrap_or_default(); + + assemble_displays(rows, &layout_policy, &topology) + } + + /// The desktop position for a display just appended to its group (design §6.2): the group's + /// `existing` members (each with its acquire `gen`) plus `new` last, ordered by `gen`, arranged by + /// the pure [`layout`] engine, taking the new member's placement. Pure — so the append-in-acquire- + /// order + auto-row/manual arrangement is unit-tested independent of the pool/global. + fn position_for_new( + mut existing: Vec<(u64, crate::vdisplay::layout::Member)>, + new: crate::vdisplay::layout::Member, + layout_policy: &Layout, + ) -> crate::vdisplay::layout::Placement { + existing.sort_by_key(|(g, _)| *g); + let mut members: Vec = + existing.into_iter().map(|(_, m)| m).collect(); + members.push(new); + *crate::vdisplay::layout::arrange(&members, layout_policy) + .last() + .expect("members is non-empty (just pushed `new`)") + } + + /// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2): group = backend + /// (one desktop per compositor session), ordered by acquire (`gen`), with each member's position + /// from the pure [`layout`] engine. Pure — no I/O, no global — so the grouping / ordering / position + /// assignment is unit-tested against synthetic rows. + fn assemble_displays( + rows: Vec, + layout_policy: &Layout, + topology: &str, + ) -> Vec { + use crate::vdisplay::layout::{self, Member}; + + // Small stable group ids by sorted backend name — deterministic; in practice a host runs one + // live backend → group 1. + let mut backends: Vec<&'static str> = rows.iter().map(|row| row.backend).collect(); + backends.sort_unstable(); + backends.dedup(); + + let mut out: Vec = Vec::new(); + for (gi, backend) in backends.iter().enumerate() { + // This group's members in acquire order (gen ascending) → display_index + arrangement. + let mut idx: Vec = rows + .iter() + .enumerate() + .filter(|(_, row)| row.backend == *backend) + .map(|(i, _)| i) + .collect(); + idx.sort_by_key(|&i| rows[i].gen); + let members: Vec = idx + .iter() + .map(|&i| Member { + identity_slot: rows[i].identity_slot, + width: rows[i].mode.width as i32, + }) + .collect(); + let places = layout::arrange(&members, layout_policy); + for (ord, &i) in idx.iter().enumerate() { + let row = &rows[i]; + let p = places[ord]; + out.push(DisplayInfo { + slot: row.gen, + backend: row.backend.to_string(), + mode: (row.mode.width, row.mode.height, row.mode.refresh_hz), + state: row.state.to_string(), + expires_in_ms: row.expires_in_ms, + sessions: row.sessions, + client: None, + group: gi as u32 + 1, + display_index: ord as u32, + position: (p.x, p.y), + identity_slot: row.identity_slot, + topology: topology.to_string(), + }); + } + } + out } pub(super) fn force_release(slot: Option) -> usize { @@ -415,4 +593,105 @@ mod linux { release(self.gen); } } + + #[cfg(test)] + mod tests { + use super::*; + use crate::vdisplay::policy::{Layout, LayoutMode, Position}; + use std::collections::BTreeMap; + + fn row(gen: u64, backend: &'static str, w: u32, slot: Option) -> Row { + Row { + gen, + backend, + mode: Mode { + width: w, + height: 1080, + refresh_hz: 60, + }, + identity_slot: slot, + state: "active", + expires_in_ms: None, + sessions: 1, + } + } + + #[test] + fn groups_by_backend_and_auto_rows_in_acquire_order() { + // Two KWin displays (acquired gen 5 then gen 2 — deliberately out of vec order) + a Mutter one. + let rows = vec![ + row(5, "kwin", 2560, Some(1)), + row(2, "kwin", 1920, Some(7)), + row(9, "mutter", 3840, None), + ]; + let out = assemble_displays(rows, &Layout::default(), "exclusive"); + + let kwin: Vec<&DisplayInfo> = out.iter().filter(|d| d.backend == "kwin").collect(); + assert_eq!(kwin.len(), 2); + assert_eq!(kwin[0].slot, 2); // lower gen (earlier acquire) sorts to index 0 + assert_eq!(kwin[0].display_index, 0); + assert_eq!(kwin[0].position, (0, 0)); + assert_eq!(kwin[1].slot, 5); + assert_eq!(kwin[1].display_index, 1); + assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px gen-2 display + assert_eq!(kwin[0].topology, "exclusive"); + + // A distinct backend is a distinct group. + let mutter = out.iter().find(|d| d.backend == "mutter").unwrap(); + assert_ne!(mutter.group, kwin[0].group); + assert_eq!(mutter.display_index, 0); + assert_eq!(mutter.position, (0, 0)); + } + + #[test] + fn position_for_new_appends_right_in_acquire_order() { + use crate::vdisplay::layout::{Member, Placement}; + let m = |slot, w| Member { + identity_slot: slot, + width: w, + }; + // Existing group (given out of gen order): gen 8 @ 1920 acquired AFTER gen 3 @ 2560. + let existing = vec![(8, m(Some(2), 1920)), (3, m(Some(1), 2560))]; + // A new 1280-wide display appends to the right of 2560 + 1920. + let pos = position_for_new(existing, m(Some(5), 1280), &Layout::default()); + assert_eq!(pos, Placement { x: 4480, y: 0 }); + // First-of-group lands at the origin (so the registry skips the apply). + let first = position_for_new(vec![], m(None, 3840), &Layout::default()); + assert_eq!(first, Placement { x: 0, y: 0 }); + } + + #[test] + fn position_for_new_honors_a_manual_pin() { + use crate::vdisplay::layout::{Member, Placement}; + let mut positions = BTreeMap::new(); + positions.insert("5".to_string(), Position { x: 100, y: 200 }); + let layout = Layout { + mode: LayoutMode::Manual, + positions, + }; + let new = Member { + identity_slot: Some(5), + width: 1280, + }; + let pos = position_for_new(vec![(1, new)], new, &layout); + assert_eq!(pos, Placement { x: 100, y: 200 }); + } + + #[test] + fn manual_layout_keys_positions_by_identity_slot() { + // Client 7 arranged to the LEFT of client 1 (reversed vs. auto-row). + let rows = vec![row(1, "kwin", 2560, Some(1)), row(2, "kwin", 1920, Some(7))]; + let mut positions = BTreeMap::new(); + positions.insert("1".to_string(), Position { x: 1920, y: 0 }); + positions.insert("7".to_string(), Position { x: 0, y: 0 }); + let layout = Layout { + mode: LayoutMode::Manual, + positions, + }; + let out = assemble_displays(rows, &layout, "extend"); + let by_slot = |s: u32| out.iter().find(|d| d.identity_slot == Some(s)).unwrap(); + assert_eq!(by_slot(1).position, (1920, 0)); + assert_eq!(by_slot(7).position, (0, 0)); + } + } } diff --git a/design/display-management.md b/design/display-management.md index 06818f7..b6d3038 100644 --- a/design/display-management.md +++ b/design/display-management.md @@ -1,8 +1,11 @@ # Virtual-display management & lifecycle policy — design -> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 STARTED** (branch -> `display-mgmt-stage0`, not yet merged). See the **Status — handoff** block under §11 for the -> per-stage state, the key decisions (notably the Windows `reject` default), and what's left. +> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 IN PROGRESS** (branch +> `display-mgmt-stage0`, not yet merged). Stage 5 so far: group-aware KWin `exclusive` (§6.1) + the +> **layout foundation** — a pure arrangement engine (`vdisplay/layout.rs`, auto-row + manual), the +> `PUT /display/layout` mgmt endpoint, group/position/index surfaced in `/display/state`, and KWin +> manual-position apply. See the **Status — handoff** block under §11 for the per-stage state, the key +> decisions (notably the Windows `reject` default), and what's left. > 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 @@ -667,9 +670,17 @@ GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `f via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`), 4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies). -- **Stage 5: STARTED** — only the critical §6.1 **group-aware exclusive** fix for KWin has landed +- **Stage 5: IN PROGRESS.** Landed: (a) the §6.1 **group-aware exclusive** fix for KWin (`kwin.rs` `MANAGED_PREFIX` + first-slot-wins), unit-tested but NOT yet driven by two concurrent - sessions on-glass. Everything else in Stage 5 is TODO. + sessions on-glass; (b) the **layout foundation** — a pure arrangement engine + (`vdisplay/layout.rs::arrange`, auto-row + manual, unit-tested), a group model in the Linux registry + (group = backend; `/display/state` now carries `group`/`display_index`/`position`/`identity_slot`/ + `topology`, positions computed via the engine), the `PUT /api/v1/display/layout` endpoint (persists a + manual arrangement via the pure `EffectivePolicy::with_manual_layout` transform), and KWin + **manual-position apply** at create (`apply_manual_position`, guarded + best-effort — a no-op under + auto-row / an unpinned slot, so the default path is untouched). The registry reads the backend's + resolved slot via a new `VirtualDisplay::last_identity_slot` (only KWin reports one), so the + arrangement + state honestly key on per-client identity. Still TODO in Stage 5 (below). **Decisions / deltas from this plan as written — read before continuing:** - **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two @@ -723,15 +734,46 @@ Stage-5 group-aware exclusive. `join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503 unit-tested, Moonlight-pending. -- **Stage 5 — §6A multi-client monitors. [STARTED]** Display groups, group-aware exclusive/primary/ +- **Stage 5 — §6A multi-client monitors. [IN PROGRESS]** 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. - **Done so far:** KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by - the `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group - primary, unit-tested. **TODO:** Mutter + wlroots group-aware analogues (Mutter is more involved — its - sole-monitor `ApplyMonitorsConfig` must include ALL group virtuals, not just its own); layout - auto-row + manual + `/display/layout` + console table; per-group topology restore (restore the - physical only when the group's LAST member drops); gamescope groups (single-output → decline extras). + **Done so far:** + - KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the + `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary, + unit-tested. + - **Layout engine** (`vdisplay/layout.rs::arrange`): pure auto-row (left-to-right in acquire order, + top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members), + unit-tested. `manual_position` helper for a single backend-local apply. + - **Registry group model** (Linux): group = backend (one desktop per compositor session); the + `/display/state` snapshot groups entries, orders by acquire (gen), and computes each member's + `position` via the engine. `DisplayInfo` now carries `group` / `display_index` / `position` / + `identity_slot` / `topology`. The backend reports its resolved slot via the new + `VirtualDisplay::last_identity_slot` (KWin only), so the arrangement + state key on per-client identity. + - **`PUT /api/v1/display/layout`**: persists the console's manual arrangement (positions keyed by + identity slot) via the pure `EffectivePolicy::with_manual_layout` transform (locks the current + effective behavior into explicit `Custom` fields + sets a manual layout — arranging is orthogonal to + the other axes). OpenAPI regenerated. + - **Registry-driven position apply** (`VirtualDisplay::apply_position(x, y)`, default no-op; KWin + implements it via `kscreen-doctor output..position.,`): the registry owns the group, so + right after `create` it computes the new display's position over the whole group via the pure + `position_for_new` (existing same-backend members in acquire order + the new one appended last → + `layout::arrange` → the new member's placement) and calls `apply_position`. This makes **both** + auto-row (deterministic left-to-right, not just the compositor's default) **and** manual placement + go through one seam. Guarded: the registry skips the desktop origin `(0, 0)`, so a single-display / + first-of-group session (and every non-KWin backend, which no-ops `apply_position`) issues no + positioning at all — the historical single-display path is byte-for-byte unchanged. `position_for_new` + is unit-tested. *On-glass-validation-pending (kscreen positioning of a live virtual output).* + **TODO (still Stage 5):** + - **Mutter + wlroots group-aware analogues** (Mutter is more involved — its sole-monitor + `ApplyMonitorsConfig` must include ALL group virtuals, not just its own; it can't name-filter like + KWin — the registry must tell it which pre-existing connectors are managed siblings). + - **Per-group topology restore** (restore the physical only when the group's LAST member drops): KWin's + `exclusive` acquire is group-aware, but its RESTORE is still per-display (the `StopGuard` re-enables + the physical on its own teardown), so the first sibling out re-enables the physical while a sibling is + still exclusive. The clean fix moves the restore into a registry group record (run once, when the + group empties, ordered BEFORE the last member's output is reclaimed so KWin never sees zero outputs). + Needs two-session on-glass to validate — deferred alongside the group-aware-exclusive on-glass item. + - Console arrangement table (web, x/y first); gamescope groups (single-output → decline extras, §6B). *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. From 87435e654791ca4628f11bd760ed7ceb12284d8f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 13:26:25 +0000 Subject: [PATCH 21/40] =?UTF-8?q?feat(vdisplay):=20complete=20Stage=205=20?= =?UTF-8?q?=C2=A76A=20group=20semantics=20=E2=80=94=20per-group=20restore,?= =?UTF-8?q?=20Mutter=20group-aware,=20gamescope=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-side completion of Stage 5 (§6A many-clients-as-monitors), all unit-tested; two-session on-glass validation still pending (no GPU on the dev VM): - Per-group topology restore (§6.1): the KWin `exclusive` restore no longer rides the per-session StopGuard (which re-enabled the physical the moment the FIRST of several exclusive sessions dropped, under a live sibling). KWin hands its restore to the registry as a closure (new trait `take_topology_restore`); the registry keeps it in the display group (`Entry.topology_restore`) and, on teardown, floats it to a surviving same-group sibling (`hand_off_restore`) or runs it when the group empties — outside the lock, before the last output's keepalive drops, so the compositor never sees zero outputs. All three teardown paths (lease drop / linger expiry / mgmt release) honor it. Single-display path byte-for-byte unchanged. Unit-tested: float / run-on-last / non-carrier-first / never-cross-backend. - Mutter group-aware (new trait `set_first_in_group`): the registry tells each backend whether it's the first display of its group; a non-first Mutter session EXTENDS into the already-exclusive desktop instead of re-applying a sole-monitor ApplyMonitorsConfig that would disable the first session's virtual. (Mutter connectors are un-nameable, so it can't build a keep-all-virtuals config; skipping is the safe equivalent.) Single-session unchanged. Residual APPLY_TEMPORARY revert documented. - gamescope groups (§6.1): `registry::group_key` makes each gamescope spawn its own group (independent nested session, no shared desktop) — never auto-rowed against or restore-/topology-grouped with another gamescope. Applied in both the /display/state assembly and the acquire-time position computation. Unit-tested. Remaining Stage 5: the web console arrangement table, on-glass validation, and the documented residuals (wlroots exclusive, Mutter APPLY_TEMPORARY). design doc updated. cargo build/test (214)/clippy --all-targets/fmt green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/vdisplay.rs | 20 ++ .../punktfunk-host/src/vdisplay/linux/kwin.rs | 70 +++-- .../src/vdisplay/linux/mutter.rs | 50 +++- .../punktfunk-host/src/vdisplay/registry.rs | 268 ++++++++++++++++-- design/display-management.md | 94 +++--- 5 files changed, 413 insertions(+), 89 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 9884d60..728095b 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -81,6 +81,26 @@ pub trait VirtualDisplay: Send { /// single-display / first-of-group session issues no positioning at all. Best-effort — a failure /// leaves the compositor's default placement. fn apply_position(&mut self, _x: i32, _y: i32) {} + /// Take the topology **restore** action this [`create`](Self::create) prepared — the work that + /// un-does an `exclusive`/`primary` topology change (e.g. re-enable the physical outputs KWin + /// disabled). The registry lifts it into the display **group** so it runs **once, when the group's + /// last display is torn down** (design §6.1 — per-group restore), not when this one session's + /// display drops: a sibling `exclusive` session must not have the physical re-enabled under it. + /// Called right after `create`; the backend must not also run it itself. Default `None` — a backend + /// whose topology auto-reverts (Mutter `APPLY_TEMPORARY`) or that changes nothing has nothing to + /// hand off. + fn take_topology_restore(&mut self) -> Option> { + None + } + /// Tell the backend whether this create will be the **first** display in its group — i.e. no + /// sibling of the same backend is already live (design §6.1). A backend that *establishes* the + /// group's topology (Mutter's sole-monitor `exclusive` `ApplyMonitorsConfig`) applies it only when + /// first; a later sibling **extends** into the already-exclusive desktop instead of re-clobbering it + /// (a fresh sole-monitor config would disable the first session's virtual output). Set by the + /// registry right before [`create`](Self::create). Default no-op: KWin recognises siblings at + /// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display + /// backends never have a sibling. + fn set_first_in_group(&mut self, _first: bool) {} } /// Compositors punktfunk knows how to drive (plan §6). diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index 86a03cf..743a9b7 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -82,6 +82,22 @@ pub struct KwinDisplay { /// The base output name the last `create` used (`punktfunk` / `punktfunk-`) — so /// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-`. last_name: Option, + /// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive` + /// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the + /// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's. + /// A backstop [`Drop`] runs it if the registry never took it (so a physical is never left dark). + pending_restore: Option>, +} + +impl Drop for KwinDisplay { + fn drop(&mut self) { + // Backstop only: the registry takes the restore right after `create` (moving it into the group), + // so this is normally `None`. If some path skipped the take, re-enable here so a physical is + // never stranded dark. + if let Some(restore) = self.pending_restore.take() { + restore(); + } + } } impl KwinDisplay { @@ -103,6 +119,10 @@ impl VirtualDisplay for KwinDisplay { self.last_slot } + fn take_topology_restore(&mut self) -> Option> { + self.pending_restore.take() + } + fn apply_position(&mut self, x: i32, y: i32) { let Some(name) = self.last_name.clone() else { return; @@ -173,7 +193,7 @@ impl VirtualDisplay for KwinDisplay { // plasmashell + windows land on the streamed surface, not the headless `kwin --virtual` // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). use crate::vdisplay::policy::Topology; - let restore = match crate::vdisplay::effective_topology() { + let disabled = match crate::vdisplay::effective_topology() { Topology::Exclusive => apply_virtual_primary(&name), Topology::Primary => { apply_virtual_primary_only(&name); @@ -181,17 +201,44 @@ impl VirtualDisplay for KwinDisplay { } Topology::Extend | Topology::Auto => Vec::new(), }; + // Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session + // `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive + // sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts + // into the display group and runs once, when the group's LAST member is torn down (ordered before + // that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore. + self.pending_restore = (!disabled.is_empty()).then(|| { + let disabled = disabled.clone(); + Box::new(move || reenable_outputs(&disabled)) as Box + }); // Layout position (§6.2) is applied by the registry via `apply_position` right after create // (it owns the display group, so it computes auto-row / manual placement over the whole group). Ok(VirtualOutput { node_id, remote_fd: None, preferred_mode: Some((mode.width, mode.height, achieved_hz)), - keepalive: Box::new(StopGuard { stop, restore }), + keepalive: Box::new(StopGuard { stop }), }) } } +/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto +/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE +/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs. +fn reenable_outputs(outputs: &[String]) { + if outputs.is_empty() { + return; + } + let args: Vec = outputs + .iter() + .map(|o| format!("output.{o}.enable")) + .collect(); + let _ = std::process::Command::new("kscreen-doctor") + .args(&args) + .status(); + std::thread::sleep(Duration::from_millis(200)); + tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs (group empty)"); +} + /// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by /// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-`, /// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually @@ -396,28 +443,15 @@ fn apply_virtual_primary_only(name: &str) { } /// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which -/// drops the Wayland connection and makes KWin reclaim the output. +/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no +/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which +/// runs it once when the group's last member drops, BEFORE this keepalive is dropped. struct StopGuard { stop: Arc, - /// Bootstrap output(s) `apply_virtual_primary` disabled to make our streamed output the sole - /// desktop — re-enabled here FIRST, so KWin is never left with zero enabled outputs as our - /// output is reclaimed. Empty unless PUNKTFUNK_KWIN_VIRTUAL_PRIMARY is set. - restore: Vec, } impl Drop for StopGuard { fn drop(&mut self) { - if !self.restore.is_empty() { - let args: Vec = self - .restore - .iter() - .map(|o| format!("output.{o}.enable")) - .collect(); - let _ = std::process::Command::new("kscreen-doctor") - .args(&args) - .status(); - std::thread::sleep(Duration::from_millis(200)); - } self.stop.store(true, Ordering::Relaxed); } } diff --git a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs index 3c51be5..199761c 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs @@ -42,11 +42,19 @@ const CURSOR_EMBEDDED: u32 = 1; /// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a /// keepalive thread owning the D-Bus sessions behind the virtual monitor. -pub struct MutterDisplay; +pub struct MutterDisplay { + /// Whether this display is the FIRST of its group (§6.1) — set by the registry before `create`. + /// A later sibling **extends** into the already-exclusive desktop instead of re-applying the + /// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone + /// session establishes topology as before). + first_in_group: bool, +} impl MutterDisplay { pub fn new() -> Result { - Ok(MutterDisplay) + Ok(MutterDisplay { + first_in_group: true, + }) } } @@ -64,13 +72,18 @@ impl VirtualDisplay for MutterDisplay { "mutter" } + fn set_first_in_group(&mut self, first: bool) { + self.first_in_group = first; + } + fn create(&mut self, mode: Mode) -> Result { let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); + let first_in_group = self.first_in_group; thread::Builder::new() .name("punktfunk-mutter-vout".into()) - .spawn(move || session_thread(setup_tx, stop_thread, mode)) + .spawn(move || session_thread(setup_tx, stop_thread, mode, first_in_group)) .context("spawn Mutter virtual-output thread")?; let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { @@ -104,8 +117,14 @@ impl Drop for StopGuard { } /// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire -/// node id, then hold the connection until stopped. -fn session_thread(setup_tx: Sender>, stop: Arc, mode: Mode) { +/// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a +/// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it). +fn session_thread( + setup_tx: Sender>, + stop: Arc, + mode: Mode, + first_in_group: bool, +) { let rt = match tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() @@ -122,12 +141,23 @@ fn session_thread(setup_tx: Sender>, stop: Arc, // value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes // it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the // SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream. + use crate::vdisplay::policy::Topology; let topo = crate::vdisplay::effective_topology(); - let want_config = matches!( - topo, - crate::vdisplay::policy::Topology::Primary | crate::vdisplay::policy::Topology::Exclusive - ); - let exclusive = matches!(topo, crate::vdisplay::policy::Topology::Exclusive); + let topo_policy = matches!(topo, Topology::Primary | Topology::Exclusive); + // Group-aware (§6.1): only the FIRST display of the group establishes the topology. A later + // sibling extends into the already-exclusive desktop — re-applying the sole-monitor config would + // disable the first session's virtual output (Mutter connectors are un-nameable, so we can't + // build a config that keeps all group virtuals; skipping is the safe choice). *Concurrent + // Mutter exclusive is on-glass-validation-pending; the APPLY_TEMPORARY revert when the FIRST + // session leaves under a live sibling is a documented residual (design §7).* + let want_config = first_in_group && topo_policy; + if topo_policy && !first_in_group { + tracing::info!( + "mutter: joining an existing display group — extending (the first session owns the \ + exclusive/primary topology)" + ); + } + let exclusive = matches!(topo, Topology::Exclusive); // Snapshot the monitor layout BEFORE the virtual output exists (so we can tell the new // connector apart and restore on teardown) whenever we're going to touch the topology. let dc_pre = if want_config { diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 94c975e..0985290 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -190,11 +190,40 @@ mod linux { /// shared/anonymous or a backend with no per-client identity) — keys the group arrangement + /// the `/display/state` slot. Captured at create; kept across a keep-alive reuse. identity_slot: Option, + /// The topology-restore action for this display's GROUP (design §6.1): re-enable the physical + /// outputs an `exclusive` topology disabled. At most ONE entry per group carries it (the first + /// exclusive session); on teardown it hands off to a surviving sibling, and only runs when the + /// group's last member drops. `None` for extend/primary and non-first / non-exclusive members. + topology_restore: Option, /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease /// — its entry was reused + re-stamped — is a no-op). gen: u64, } + /// A per-group topology-restore action (see [`Entry::topology_restore`]). + type Restore = Box; + + /// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a + /// same-group (backend) sibling survives in `remaining`, MOVE the restore onto it (a later teardown + /// runs it); if the group is now empty, RETURN the action so the caller runs it (before dropping the + /// reclaimed display's keepalive, so the physical is re-enabled while our output still exists — + /// the compositor never sees zero outputs). `None` in → `None` out. + fn hand_off_restore( + remaining: &mut [Entry], + backend: &'static str, + restore: Option, + ) -> Option { + let action = restore?; + // At most one restore per group, so any surviving sibling has `None` to receive it. + match remaining.iter_mut().find(|e| e.backend == backend) { + Some(sibling) => { + sibling.topology_restore = Some(action); + None + } + None => Some(action), // group empty → run it now + } + } + struct Reg { entries: Mutex>, gen: AtomicU64, @@ -220,18 +249,26 @@ mod linux { /// Remove entries whose linger deadline has passed, returning them so the caller drops (tears /// them down) *after* releasing the lock — a backend keepalive `Drop` (Mutter D-Bus Stop) can - /// block, and holding the pool lock across it would stall every other acquire/release. - fn take_expired(entries: &mut Vec, now: Instant) -> Vec { + /// block, and holding the pool lock across it would stall every other acquire/release. Each + /// expired entry's topology restore is [handed off](hand_off_restore) to a surviving group sibling, + /// or collected into the returned `restores` when its group empties (run before the entries drop). + fn take_expired(entries: &mut Vec, now: Instant) -> (Vec, Vec) { let mut expired = Vec::new(); + let mut restores = Vec::new(); let mut i = 0; while i < entries.len() { if entries[i].life.poll_expiry(now) { - expired.push(entries.remove(i)); + let mut e = entries.remove(i); + let backend = e.backend; + if let Some(r) = hand_off_restore(entries, backend, e.topology_restore.take()) { + restores.push(r); + } + expired.push(e); } else { i += 1; } } - expired + (expired, restores) } /// Background thread (started once): reap lingering displays past their deadline. @@ -242,10 +279,14 @@ mod linux { .name("vdisplay-linger".into()) .spawn(|| loop { std::thread::sleep(Duration::from_millis(500)); - let expired = { + let (expired, restores) = { let mut es = reg().entries.lock().unwrap(); take_expired(&mut es, Instant::now()) }; + // Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock. + for restore in restores { + restore(); + } for e in expired { tracing::info!( backend = e.backend, @@ -277,11 +318,14 @@ mod linux { let backend = vd.name(); let r = reg(); - // Reap expired first (drop outside the lock). - let expired = { + // Reap expired first (run any group restores + drop outside the lock). + let (expired, restores) = { let mut es = r.entries.lock().unwrap(); take_expired(&mut es, Instant::now()) }; + for restore in restores { + restore(); + } drop(expired); // Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session @@ -309,6 +353,16 @@ mod linux { } } + // Tell the backend whether it's the FIRST display of its group (no same-backend sibling live, + // §6.1) — so a topology-establishing backend (Mutter exclusive) extends into an already-exclusive + // desktop rather than re-clobbering the first session's virtual. Best-effort (a concurrent create + // is a narrow race); single-session is always `first == true` → today's behavior. + let first_in_group = { + let es = r.entries.lock().unwrap(); + !es.iter().any(|e| e.backend == backend) + }; + vd.set_first_in_group(first_in_group); + // Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads). let real = vd.create(mode)?; // The identity slot the backend just resolved (KWin per-slot naming; `None` elsewhere) — keys @@ -328,6 +382,10 @@ mod linux { let node_id = real.node_id; let preferred_mode = real.preferred_mode; + // The backend's topology-restore action (KWin `exclusive` → re-enable the disabled physicals), + // lifted into the group so it runs once when the group's last member drops (§6.1), not at this + // session's teardown. `None` for non-exclusive / non-first / backends whose topology auto-reverts. + let topology_restore = vd.take_topology_restore(); let gen = r.gen.fetch_add(1, Ordering::Relaxed); let mut life = lifecycle::State::default(); life.acquire(); // Idle → Active{refs:1} (Acquire::Create) @@ -339,6 +397,7 @@ mod linux { mode, backend, identity_slot, + topology_restore, gen, }; @@ -353,9 +412,12 @@ mod linux { .map(|e| e.layout) .unwrap_or_default(); let mut es = r.entries.lock().unwrap(); + // Same-group members (design §6.1): same backend for a shared desktop, but each gamescope + // spawn is its own group, so a new gamescope never auto-rows against another. + let new_group = group_key(backend, gen); let existing: Vec<(u64, Member)> = es .iter() - .filter(|e| e.backend == backend) + .filter(|e| group_key(e.backend, e.gen) == new_group) .map(|e| { ( e.gen, @@ -390,31 +452,42 @@ mod linux { fn release(gen: u64) { let Some(r) = REG.get() else { return }; let linger = linger(); - let torn_down = { + let (torn_down, restore) = { let mut es = r.entries.lock().unwrap(); let Some(idx) = es.iter().position(|e| e.gen == gen) else { return; // stale lease (entry reused + re-stamped, or already gone) — no-op }; match es[idx].life.release(Instant::now(), linger) { - Release::Teardown | Release::Noop => Some(es.remove(idx)), + Release::Teardown | Release::Noop => { + let mut e = es.remove(idx); + let backend = e.backend; + // Per-group restore (§6.1): hand the physical re-enable to a surviving sibling, or run + // it now if this was the group's last member. + let restore = hand_off_restore(&mut es, backend, e.topology_restore.take()); + (Some(e), restore) + } Release::Linger => { tracing::info!( backend = es[idx].backend, "virtual display: last session left — lingering (keep-alive)" ); - None + (None, None) } Release::Pin => { tracing::info!( backend = es[idx].backend, "virtual display: last session left — pinned (keep-alive forever)" ); - None + (None, None) } // Linux entries are single-session (refs == 1), so Decref never occurs; harmless. - Release::Decref => None, + Release::Decref => (None, None), } }; + // Re-enable the physicals (group emptied) BEFORE dropping the output — outside the lock. + if let Some(restore) = restore { + restore(); + } if let Some(e) = torn_down { tracing::info!( backend = e.backend, @@ -498,10 +571,23 @@ mod linux { .expect("members is non-empty (just pushed `new`)") } - /// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2): group = backend - /// (one desktop per compositor session), ordered by acquire (`gen`), with each member's position - /// from the pure [`layout`] engine. Pure — no I/O, no global — so the grouping / ordering / position - /// assignment is unit-tested against synthetic rows. + /// The display **group** a backend+display belongs to (design §6.1). The desktop compositors + /// (KWin/Mutter/wlroots) put every managed output on ONE desktop → one group per backend. A + /// gamescope **spawn** is an independent nested session per client (no shared desktop), so each + /// gamescope display is its OWN group — never auto-rowed against, or topology-/restore-grouped with, + /// another gamescope session. + fn group_key(backend: &str, gen: u64) -> String { + if backend == "gamescope" { + format!("gamescope#{gen}") + } else { + backend.to_string() + } + } + + /// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2) by + /// [`group_key`], ordered by acquire (`gen`), with each member's position from the pure [`layout`] + /// engine. Pure — no I/O, no global — so the grouping / ordering / position assignment is + /// unit-tested against synthetic rows. fn assemble_displays( rows: Vec, layout_policy: &Layout, @@ -509,19 +595,19 @@ mod linux { ) -> Vec { use crate::vdisplay::layout::{self, Member}; - // Small stable group ids by sorted backend name — deterministic; in practice a host runs one - // live backend → group 1. - let mut backends: Vec<&'static str> = rows.iter().map(|row| row.backend).collect(); - backends.sort_unstable(); - backends.dedup(); + // Small stable group ids by sorted group key — deterministic; in practice a host runs one live + // desktop backend → group 1 (with each gamescope spawn its own group). + let mut keys: Vec = rows.iter().map(|r| group_key(r.backend, r.gen)).collect(); + keys.sort(); + keys.dedup(); let mut out: Vec = Vec::new(); - for (gi, backend) in backends.iter().enumerate() { + for (gi, key) in keys.iter().enumerate() { // This group's members in acquire order (gen ascending) → display_index + arrangement. let mut idx: Vec = rows .iter() .enumerate() - .filter(|(_, row)| row.backend == *backend) + .filter(|(_, row)| &group_key(row.backend, row.gen) == key) .map(|(i, _)| i) .collect(); idx.sort_by_key(|&i| rows[i].gen); @@ -557,21 +643,32 @@ mod linux { pub(super) fn force_release(slot: Option) -> usize { let Some(r) = REG.get() else { return 0 }; - let released = { + let (released, restores) = { let mut es = r.entries.lock().unwrap(); let mut out = Vec::new(); + let mut restores = Vec::new(); let mut i = 0; while i < es.len() { let selected = slot.is_none_or(|s| es[i].gen == s); if selected && es[i].life.force_release() { - out.push(es.remove(i)); + let mut e = es.remove(i); + let backend = e.backend; + let restore = e.topology_restore.take(); + if let Some(rst) = hand_off_restore(&mut es, backend, restore) { + restores.push(rst); + } + out.push(e); } else { i += 1; } } - out + (out, restores) }; let n = released.len(); + // Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock. + for restore in restores { + restore(); + } for e in released { tracing::info!( backend = e.backend, @@ -599,6 +696,106 @@ mod linux { use super::*; use crate::vdisplay::policy::{Layout, LayoutMode, Position}; use std::collections::BTreeMap; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + /// A minimal pool entry for the pure teardown/restore tests (dummy keepalive; the + /// `hand_off_restore` logic only reads `backend` + `topology_restore`). + fn test_entry(backend: &'static str, gen: u64, restore: Option) -> Entry { + Entry { + life: lifecycle::State::default(), + keepalive: Box::new(()), + node_id: 0, + preferred_mode: None, + mode: Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }, + backend, + identity_slot: None, + topology_restore: restore, + gen, + } + } + + /// A restore closure that flips `flag` when run — so a test can assert exactly WHEN it fires. + fn flag_restore(flag: &Arc) -> Restore { + let f = flag.clone(); + Box::new(move || f.store(true, Ordering::SeqCst)) + } + + #[test] + fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() { + let ran = Arc::new(AtomicBool::new(false)); + // Two KWin displays in one group; the first (gen 1) carries the group's restore. + let mut pool = vec![ + test_entry("kwin", 1, Some(flag_restore(&ran))), + test_entry("kwin", 2, None), + ]; + + // Tear down the restore-carrier while its sibling is still alive → transfer, don't run. + let mut e1 = pool.remove(0); + let out = hand_off_restore(&mut pool, "kwin", e1.topology_restore.take()); + assert!(out.is_none(), "transferred, not run"); + assert!(!ran.load(Ordering::SeqCst)); + // The restore floated onto the surviving sibling. + assert!(pool[0].topology_restore.is_some()); + + // Tear down the last member → group empty → the restore is returned to run. + let mut e2 = pool.remove(0); + let out = hand_off_restore(&mut pool, "kwin", e2.topology_restore.take()); + let action = out.expect("group empty → run the restore"); + assert!(!ran.load(Ordering::SeqCst), "not run yet"); + action(); + assert!(ran.load(Ordering::SeqCst), "runs on the last drop"); + } + + #[test] + fn single_session_topology_restore_runs_on_its_own_teardown() { + // The validated single-display case: one exclusive session → restore runs at its teardown. + let ran = Arc::new(AtomicBool::new(false)); + let mut pool = vec![test_entry("kwin", 1, Some(flag_restore(&ran)))]; + let mut e = pool.remove(0); + let action = hand_off_restore(&mut pool, "kwin", e.topology_restore.take()) + .expect("last (only) member → run"); + action(); + assert!(ran.load(Ordering::SeqCst)); + } + + #[test] + fn tearing_down_a_non_carrier_first_leaves_the_restore_for_last() { + let ran = Arc::new(AtomicBool::new(false)); + // gen 2 carries the restore; gen 1 does not (a later exclusive session found the physical + // already disabled). + let mut pool = vec![ + test_entry("kwin", 1, None), + test_entry("kwin", 2, Some(flag_restore(&ran))), + ]; + // Tear down the non-carrier first → nothing to hand off, carrier untouched. + let mut e1 = pool.remove(0); + assert!(hand_off_restore(&mut pool, "kwin", e1.topology_restore.take()).is_none()); + // The carrier (gen 2) still holds the group's restore. + assert!(pool[0].topology_restore.is_some()); + // Now the carrier (last member) → run. + let mut e2 = pool.remove(0); + hand_off_restore(&mut pool, "kwin", e2.topology_restore.take()) + .expect("last member → run")(); + assert!(ran.load(Ordering::SeqCst)); + } + + #[test] + fn restore_never_floats_across_backends() { + // group = backend: a KWin restore must not land on a Mutter display (a different desktop). + let ran = Arc::new(AtomicBool::new(false)); + let mut pool = vec![test_entry("mutter", 2, None)]; + let out = hand_off_restore(&mut pool, "kwin", Some(flag_restore(&ran))); + assert!(out.is_some(), "no same-backend sibling → return to run"); + assert!( + pool[0].topology_restore.is_none(), + "restore must not cross into another backend's group" + ); + } fn row(gen: u64, backend: &'static str, w: u32, slot: Option) -> Row { Row { @@ -677,6 +874,23 @@ mod linux { assert_eq!(pos, Placement { x: 100, y: 200 }); } + #[test] + fn gamescope_spawns_are_separate_groups() { + // Two independent gamescope spawns must NOT share a group or auto-row against each other. + let rows = vec![ + row(1, "gamescope", 1920, None), + row(2, "gamescope", 1280, None), + ]; + let out = assemble_displays(rows, &Layout::default(), "extend"); + assert_eq!(out.len(), 2); + assert_ne!(out[0].group, out[1].group, "distinct groups"); + // Each is display 0 of its own group, at the origin (not auto-rowed against the other). + assert_eq!(out[0].display_index, 0); + assert_eq!(out[1].display_index, 0); + assert_eq!(out[0].position, (0, 0)); + assert_eq!(out[1].position, (0, 0)); + } + #[test] fn manual_layout_keys_positions_by_identity_slot() { // Client 7 arranged to the LEFT of client 1 (reversed vs. auto-row). diff --git a/design/display-management.md b/design/display-management.md index b6d3038..608bd8b 100644 --- a/design/display-management.md +++ b/design/display-management.md @@ -1,11 +1,15 @@ # Virtual-display management & lifecycle policy — design -> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 IN PROGRESS** (branch -> `display-mgmt-stage0`, not yet merged). Stage 5 so far: group-aware KWin `exclusive` (§6.1) + the -> **layout foundation** — a pure arrangement engine (`vdisplay/layout.rs`, auto-row + manual), the -> `PUT /display/layout` mgmt endpoint, group/position/index surfaced in `/display/state`, and KWin -> manual-position apply. See the **Status — handoff** block under §11 for the per-stage state, the key -> decisions (notably the Windows `reject` default), and what's left. +> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 HOST-SIDE DONE** (branch +> `display-mgmt-stage0`, not yet merged). Stage 5 §6A host-side complete: display **groups** +> (`registry::group_key` — one per desktop backend, each gamescope spawn its own), group-aware +> `exclusive`/`primary` (KWin name-filter + first-slot-wins; Mutter `set_first_in_group`), **per-group +> topology restore** (KWin restore floats through the group, runs on the last member's teardown), the +> **layout engine** (`vdisplay/layout.rs`, auto-row + manual) + registry-driven `apply_position`, and the +> `PUT /display/layout` endpoint with group/position/index in `/display/state`. **Remaining Stage 5:** the +> web console arrangement table + on-glass validation (2 clients on a GPU box) + a couple of documented +> residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY` revert). See the **Status — handoff** block +> under §11 for the per-stage state and the key decisions (notably the Windows `reject` default). > 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 @@ -670,17 +674,20 @@ GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `f via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`), 4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies). -- **Stage 5: IN PROGRESS.** Landed: (a) the §6.1 **group-aware exclusive** fix for KWin - (`kwin.rs` `MANAGED_PREFIX` + first-slot-wins), unit-tested but NOT yet driven by two concurrent - sessions on-glass; (b) the **layout foundation** — a pure arrangement engine - (`vdisplay/layout.rs::arrange`, auto-row + manual, unit-tested), a group model in the Linux registry - (group = backend; `/display/state` now carries `group`/`display_index`/`position`/`identity_slot`/ - `topology`, positions computed via the engine), the `PUT /api/v1/display/layout` endpoint (persists a - manual arrangement via the pure `EffectivePolicy::with_manual_layout` transform), and KWin - **manual-position apply** at create (`apply_manual_position`, guarded + best-effort — a no-op under - auto-row / an unpinned slot, so the default path is untouched). The registry reads the backend's - resolved slot via a new `VirtualDisplay::last_identity_slot` (only KWin reports one), so the - arrangement + state honestly key on per-client identity. Still TODO in Stage 5 (below). +- **Stage 5: HOST-SIDE DONE (web table + on-glass pending).** All §6A group semantics landed + unit-tested + (no two-session on-glass possible on the GPU-less dev VM): **display groups** (`registry::group_key` — one + per desktop backend, each gamescope spawn its own group), **group-aware exclusive/primary** (KWin + `MANAGED_PREFIX` + first-slot-wins; Mutter `set_first_in_group` → a non-first session extends rather than + re-clobbering), **per-group topology restore** (KWin hands its restore to the registry via + `take_topology_restore`; `Entry::topology_restore` + `hand_off_restore` float it to a surviving sibling + and run it only when the group empties, before the last output drops — all 3 teardown paths), the pure + **layout engine** (`vdisplay/layout.rs::arrange`, auto-row + manual) + **registry-driven `apply_position`** + (`position_for_new` over the whole group; skips the origin so the single-display path is unchanged), the + `PUT /api/v1/display/layout` endpoint (`EffectivePolicy::with_manual_layout`), and `/display/state` now + carrying `group`/`display_index`/`position`/`identity_slot`/`topology`. The registry keys the arrangement + on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). **Remaining:** the web arrangement + table + on-glass validation + the documented residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY` + revert) — see the Stage 5 entry below. **Decisions / deltas from this plan as written — read before continuing:** - **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two @@ -734,10 +741,10 @@ Stage-5 group-aware exclusive. `join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503 unit-tested, Moonlight-pending. -- **Stage 5 — §6A multi-client monitors. [IN PROGRESS]** 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. - **Done so far:** +- **Stage 5 — §6A multi-client monitors. [HOST-SIDE DONE ✓ — web table + on-glass pending]** 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. + **Done:** - KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary, unit-tested. @@ -763,20 +770,39 @@ Stage-5 group-aware exclusive. first-of-group session (and every non-KWin backend, which no-ops `apply_position`) issues no positioning at all — the historical single-display path is byte-for-byte unchanged. `position_for_new` is unit-tested. *On-glass-validation-pending (kscreen positioning of a live virtual output).* + - **Per-group topology restore** (design §6.1 — restore the physical only when the group's LAST member + drops): the KWin `exclusive` restore no longer rides the per-session `StopGuard` (which would re-enable + the physical the moment the FIRST of several exclusive sessions dropped, under a live sibling). KWin + now hands the restore to the registry as a closure (`take_topology_restore`); the registry keeps it in + the display **group** (`Entry::topology_restore`) and, on teardown, **floats** it to a surviving + same-group sibling (`hand_off_restore`) or, when the group empties, runs it — outside the lock, BEFORE + the last output's keepalive drops, so the compositor never sees zero outputs. All three teardown paths + (lease drop / linger expiry / mgmt release) honor it. The single-display path is byte-for-byte + unchanged (one member → run on its teardown). `hand_off_restore` is unit-tested (float / run-on-last / + non-carrier-first / never-cross-backend). *Residual concurrent-connect race + two-session on-glass + validation pending.* + - **Mutter group-aware** (`set_first_in_group`): the registry tells each backend whether it is the + FIRST display of its group; a non-first Mutter session **extends** into the already-exclusive desktop + instead of re-applying a sole-monitor `ApplyMonitorsConfig` that would disable the first session's + virtual. (Simpler than the originally-planned "include all group virtuals," which Mutter can't do — + its connectors are un-nameable — and achieves the same connect-time outcome.) Single-session unchanged + (`first == true`). *Residual: Mutter `APPLY_TEMPORARY` reverts the topology when the FIRST session + leaves under a live sibling (§7) — a full fix needs a group-owned `DisplayConfig` connection; deferred. + Concurrent-Mutter on-glass validation pending (even ≥2 `RecordVirtual` monitors is unproven).* + - **gamescope groups** (design §6.1): a gamescope **spawn** is an independent nested session per client + (no shared desktop), so `registry::group_key` makes each gamescope display its OWN group — never + auto-rowed against, topology-grouped with, or restore-grouped with another gamescope. Unit-tested. + (§6B single-output "decline extras" is Stage 6.) **TODO (still Stage 5):** - - **Mutter + wlroots group-aware analogues** (Mutter is more involved — its sole-monitor - `ApplyMonitorsConfig` must include ALL group virtuals, not just its own; it can't name-filter like - KWin — the registry must tell it which pre-existing connectors are managed siblings). - - **Per-group topology restore** (restore the physical only when the group's LAST member drops): KWin's - `exclusive` acquire is group-aware, but its RESTORE is still per-display (the `StopGuard` re-enables - the physical on its own teardown), so the first sibling out re-enables the physical while a sibling is - still exclusive. The clean fix moves the restore into a registry group record (run once, when the - group empties, ordered BEFORE the last member's output is reclaimed so KWin never sees zero outputs). - Needs two-session on-glass to validate — deferred alongside the group-aware-exclusive on-glass item. - - Console arrangement table (web, x/y first); gamescope groups (single-output → decline extras, §6B). - *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. + - **Console arrangement table (web)** — an x/y editor in the `Virtual displays` card reading + `/display/state` and writing `PUT /display/layout` (x/y table first; drag mini-map is the stretch). + The host API + persistence are done; this is the remaining web-only piece. + - **wlroots group-aware exclusive** stays deferred: wlroots `exclusive` is not implemented at all (needs + a Sway box), so there is no topology to make group-aware yet. §6A multi-view on wlroots already works + (independent `HEADLESS-N` outputs). + *Validate (all on-glass, needs a GPU box + 2 clients — not the dev VM):* 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 From a7ff1cf312c0f0dd8dcff885a620771348e0b86f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 13:32:02 +0000 Subject: [PATCH 22/40] =?UTF-8?q?feat(web):=20display=20arrangement=20tabl?= =?UTF-8?q?e=20=E2=80=94=20the=20Stage=205=20console=20x/y=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Stage 5's web piece (design/display-management.md §6.2): a `DisplayArrangement` editor in the Virtual displays card. For a ≥2-display group, it renders an x/y table over the live displays that carry a stable identity slot (the manual-layout key), seeded from the current computed positions; Save writes `PUT /display/layout` (via the generated `useSetDisplayLayout`), which switches the host to a manual layout applied from the next connect. Shared/anonymous displays (no identity slot) are omitted (they can't be pinned). Also refreshes the now-stale `display_pending_note` copy (conflict/identity/layout ARE enforced as of Stages 3-5) in en + de. web tsc + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/messages/de.json | 5 +- web/messages/en.json | 5 +- web/src/sections/Host/DisplayCard.tsx | 90 +++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/web/messages/de.json b/web/messages/de.json index 215788f..19fb54b 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -67,7 +67,7 @@ "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.", + "display_pending_note": "Änderungen greifen ab der nächsten Verbindung — eine laufende Sitzung behält die Anzeige, mit der sie gestartet ist.", "display_live": "Aktive Displays", "display_none_live": "Derzeit keine virtuellen Displays.", "display_state_active": "Aktiv", @@ -77,6 +77,9 @@ "display_release_all": "Alle gehaltenen freigeben", "display_expires_in": "Abbau in {sec}s", "display_sessions": "{count} streamend", + "display_arrange": "Anzeigen anordnen", + "display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.", + "display_arrange_save": "Anordnung speichern", "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 f4c4399..e0e50e0 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -67,7 +67,7 @@ "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.", + "display_pending_note": "Changes apply from the next connection — a streaming session keeps the display it opened on.", "display_live": "Live displays", "display_none_live": "No virtual displays right now.", "display_state_active": "Active", @@ -77,6 +77,9 @@ "display_release_all": "Release all kept", "display_expires_in": "tears down in {sec}s", "display_sessions": "{count} streaming", + "display_arrange": "Arrange displays", + "display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.", + "display_arrange_save": "Save arrangement", "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 index 4ba51b0..13e50d6 100644 --- a/web/src/sections/Host/DisplayCard.tsx +++ b/web/src/sections/Host/DisplayCard.tsx @@ -7,6 +7,7 @@ import { useGetDisplaySettings, useGetDisplayState, useReleaseDisplay, + useSetDisplayLayout, useSetDisplaySettings, } from "@/api/gen/display/display"; import type { ApiDisplayInfo } from "@/api/gen/model"; @@ -126,6 +127,95 @@ const LiveDisplays: FC = () => { ))} )} + + + ); +}; + +/** + * The multi-monitor **arrangement** editor (design/display-management.md §6.2): an x/y table over the + * live displays that carry a stable identity slot (the manual-layout key). Saving writes + * `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect. + * Shown only for a ≥2-display group — arranging a single display is moot. + */ +const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => { + const qc = useQueryClient(); + const saveLayout = useSetDisplayLayout(); + // Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key). + const arrangeable = displays.filter((d) => d.identity_slot != null); + + // Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions. + const [pos, setPos] = useState | null>(null); + useEffect(() => { + if (pos === null && arrangeable.length > 0) { + const seed: Record = {}; + for (const d of arrangeable) seed[String(d.identity_slot)] = { x: d.x, y: d.y }; + setPos(seed); + } + }, [arrangeable, pos]); + + if (arrangeable.length < 2) return null; + const cur = pos ?? {}; + + const setXY = (slot: number, key: "x" | "y", val: number) => { + const k = String(slot); + setPos({ ...cur, [k]: { ...(cur[k] ?? { x: 0, y: 0 }), [key]: val } }); + }; + + const onSave = () => + saveLayout.mutate( + { data: { positions: cur } }, + { onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) }, + ); + + return ( +
    +

    {m.display_arrange()}

    +

    {m.display_arrange_help()}

    +
    + {arrangeable.map((d) => { + const slot = d.identity_slot as number; + const p = cur[String(slot)] ?? { x: d.x, y: d.y }; + return ( +
    + + {d.mode}{" "} + #{slot} + + + setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))} + /> + + setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))} + /> +
    + ); + })} +
    + {saveLayout.error && ( +

    + {apiErrorMessage(saveLayout.error)} +

    + )} +
    ); }; From fa45608628b0dcb73762b629713d5b574836f764 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 13:33:05 +0000 Subject: [PATCH 23/40] =?UTF-8?q?docs(display-management):=20Stage=205=20h?= =?UTF-8?q?ost=20+=20web=20build=20complete=20=E2=80=94=20only=20on-glass?= =?UTF-8?q?=20validation=20+=20residuals=20left?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark the web arrangement table done and narrow "remaining Stage 5" to validation (2 clients on a GPU box, not the dev VM) plus the two documented residuals (wlroots exclusive, Mutter APPLY_TEMPORARY revert). No further host/web build work in Stage 5. Co-Authored-By: Claude Opus 4.8 (1M context) --- design/display-management.md | 37 ++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/design/display-management.md b/design/display-management.md index 608bd8b..06c5bf0 100644 --- a/design/display-management.md +++ b/design/display-management.md @@ -6,10 +6,12 @@ > `exclusive`/`primary` (KWin name-filter + first-slot-wins; Mutter `set_first_in_group`), **per-group > topology restore** (KWin restore floats through the group, runs on the last member's teardown), the > **layout engine** (`vdisplay/layout.rs`, auto-row + manual) + registry-driven `apply_position`, and the -> `PUT /display/layout` endpoint with group/position/index in `/display/state`. **Remaining Stage 5:** the -> web console arrangement table + on-glass validation (2 clients on a GPU box) + a couple of documented -> residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY` revert). See the **Status — handoff** block -> under §11 for the per-stage state and the key decisions (notably the Windows `reject` default). +> `PUT /display/layout` endpoint with group/position/index in `/display/state`, and the **web console +> arrangement table** (x/y editor → `PUT /display/layout`). **Remaining Stage 5 = validation + residuals +> only** (no more build work): on-glass validation (2 clients on a GPU box, not the dev VM) + two +> documented residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY` revert). See the **Status — +> handoff** block under §11 for the per-stage state and the key decisions (notably the Windows `reject` +> default). > 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 @@ -685,9 +687,10 @@ GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `f (`position_for_new` over the whole group; skips the origin so the single-display path is unchanged), the `PUT /api/v1/display/layout` endpoint (`EffectivePolicy::with_manual_layout`), and `/display/state` now carrying `group`/`display_index`/`position`/`identity_slot`/`topology`. The registry keys the arrangement - on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). **Remaining:** the web arrangement - table + on-glass validation + the documented residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY` - revert) — see the Stage 5 entry below. + on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). The **web arrangement table** + (`DisplayCard.tsx` `DisplayArrangement`, en+de) is also done. **Remaining = validation + residuals only:** + on-glass validation + the documented residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY` revert) — + see the Stage 5 entry below. **Decisions / deltas from this plan as written — read before continuing:** - **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two @@ -793,16 +796,22 @@ Stage-5 group-aware exclusive. (no shared desktop), so `registry::group_key` makes each gamescope display its OWN group — never auto-rowed against, topology-grouped with, or restore-grouped with another gamescope. Unit-tested. (§6B single-output "decline extras" is Stage 6.) - **TODO (still Stage 5):** - - **Console arrangement table (web)** — an x/y editor in the `Virtual displays` card reading - `/display/state` and writing `PUT /display/layout` (x/y table first; drag mini-map is the stretch). - The host API + persistence are done; this is the remaining web-only piece. + - **Console arrangement table (web)** [DONE ✓]: a `DisplayArrangement` x/y editor in the `Virtual + displays` card (`web/src/sections/Host/DisplayCard.tsx`) — for a ≥2-display group it renders an x/y + table over the live displays that carry an identity slot, seeded from `/display/state`, and Save + writes `PUT /display/layout` (switches the host to a manual layout, applied next connect). en+de + i18n; the stale `display_pending_note` copy refreshed. tsc + vite build green. (Drag mini-map is a + later stretch.) + **Remaining Stage 5 — validation + deferred residuals only (no more host/web build work):** + - **On-glass validation** (needs a GPU box + 2 clients — NOT the GPU-less dev VM): 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, the sibling is unaffected, and the physical is restored only after + BOTH drop (the per-group restore). Plus the concurrent-Mutter case on a GNOME box. - **wlroots group-aware exclusive** stays deferred: wlroots `exclusive` is not implemented at all (needs a Sway box), so there is no topology to make group-aware yet. §6A multi-view on wlroots already works (independent `HEADLESS-N` outputs). - *Validate (all on-glass, needs a GPU box + 2 clients — not the dev VM):* 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. + - **Mutter `APPLY_TEMPORARY` disconnect-revert** (§7): when the FIRST Mutter session leaves under a live + sibling, Mutter reverts the topology — a full fix needs a group-owned `DisplayConfig` connection. - **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 From 677a4f4cf5464554820bbc66cabc5c3028d3bb53 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 13:53:43 +0000 Subject: [PATCH 24/40] perf(gamestream): move FEC packetization off the encode loop (3-stage pipeline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FEC/Reed-Solomon packetization ran inline on the encode loop (~3 ms/frame at 4K), serializing behind encode and capping the GameStream frame rate below what the encoder alone can sustain. Split it into a 3-stage pipeline, each stage on its own thread joined by a depth-2 bounded queue: encode loop → [raw AUs] → packetizer (FEC/RS) → [wire batch] → paced sender - `spawn_packetizer`: turns each `RawFrame`'s access units into wire datagrams via the stateful VideoPacketizer, off the encode loop. Above-normal priority (on the per-frame critical path). Tallies goodput (bytes to the wire) for the stats window. - Backpressure chains up: a slow sender blocks the packetizer, which fills the encode→packetizer queue, which makes the encode loop drop the NEWEST frame — encode itself never waits. - A dropped frame now consumes no client-visible frameIndex (packetization is downstream), so the host re-anchors the reference chain: a drop arms a keyframe on the next iteration (`recover_after_drop`), routed through the same coalesce gate as client IDR requests so a burst of drops (congestion) can't become an IDR storm. - Perf/stats relabeled: `pkt` = AU drain, `send` = enqueue to the pipeline (both should be near-zero now; nonzero = encode being stalled by pipeline backpressure). Goodput read from the packetizer's atomic at the 1 s stats boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/gamestream/stream.rs | 143 +++++++++++++----- 1 file changed, 103 insertions(+), 40 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 04c4731..2380a0f 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -413,6 +413,54 @@ fn pace_layout(n: usize) -> (usize, usize) { (chunk_sz, steps) } +/// One encoded frame handed from the encode loop to the packetizer thread: the frame's access +/// units (owned buffers, each with its frame type) plus the shared 90 kHz RTP timestamp. FEC +/// packetization runs on the packetizer thread — off the encode loop — so it never serializes +/// behind encode (measured ~3 ms/frame at 4K, which capped GameStream's frame rate well below what +/// the encoder alone can sustain). +struct RawFrame { + aus: Vec<(Vec, FrameType)>, + ts: u32, +} + +/// Packetizer thread: turns each [`RawFrame`]'s access units into wire datagrams (data + Reed–Solomon +/// FEC parity shards) via the stateful [`VideoPacketizer`], then hands the batch to the paced sender. +/// It sits between encode and send so the FEC never blocks the encode loop. Backpressure: the hand-off +/// to the sender BLOCKS, so if the paced sender falls behind, the packetizer stalls and the +/// encode→packetizer queue fills — the encode loop then drops the newest frame (see the loop) rather +/// than stalling. Tallies goodput (bytes handed to the wire) into `goodput` for the encode loop's stats +/// window. Exits when either neighbor's channel closes (session teardown / client gone). +fn spawn_packetizer( + rx: std::sync::mpsc::Receiver, + tx: std::sync::mpsc::SyncSender, + mut pk: VideoPacketizer, + goodput: Arc, +) -> Result<()> { + std::thread::Builder::new() + .name("punktfunk-pkt".into()) + .spawn(move || { + // Above-normal, like the send thread — this stage is on the per-frame critical path. + crate::punktfunk1::boost_thread_priority(false); + while let Ok(frame) = rx.recv() { + let mut batch: PacketBatch = Vec::new(); + for (au, ft) in frame.aus { + batch.extend(pk.packetize(&au, ft, frame.ts)); + } + if batch.is_empty() { + continue; + } + let bytes: u64 = batch.iter().map(|p| p.len() as u64).sum(); + // Blocking send: propagates the paced sender's backpressure upstream (see above). + if tx.send(batch).is_err() { + break; // sender exited (client gone) + } + goodput.fetch_add(bytes, std::sync::atomic::Ordering::Relaxed); + } + }) + .context("spawn packetizer thread")?; + Ok(()) +} + /// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in /// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval /// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode @@ -544,7 +592,7 @@ fn stream_body( .ok() .and_then(|v| v.parse().ok()) .unwrap_or(20); - let mut pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec); + let pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec); // Pace at the client's negotiated frame rate, re-encoding the last captured frame when the // compositor produced no new one. Compositors only emit frames on damage, so a static or @@ -564,9 +612,15 @@ fn stream_body( let mut sent_batches: u64 = 0; let mut dropped_batches: u64 = 0; - // The send thread: one frame's batch at a time over a small bounded queue. Depth 2 means a - // slow send can buffer one frame while the next encodes; beyond that the NEWEST batch is - // dropped (the client recovers via FEC/RFI) rather than ever stalling the encode loop. + // Three-stage pipeline so FEC packetization never blocks encode: `encode loop → [raw AUs] → + // packetizer (FEC/RS) → [wire batch] → paced sender`, each stage on its own thread joined by a + // depth-2 bounded queue. Depth 2 means a slow stage can buffer one frame while the next is + // produced; beyond that the NEWEST frame is dropped (the client recovers via FEC/RFI) rather than + // stalling the encode loop. Backpressure chains up: a slow sender blocks the packetizer, which + // fills the encode→packetizer queue, which makes the encode loop drop — encode itself never + // waits. Goodput (bytes handed to the wire) is tallied by the packetizer into `goodput`, read at + // the encode loop's 1 s stats boundary (the old inline batch-byte sum moved with packetization). + let goodput = Arc::new(std::sync::atomic::AtomicU64::new(0)); let (batch_tx, batch_rx) = std::sync::mpsc::sync_channel::(2); spawn_sender( sock.try_clone().context("clone video socket")?, @@ -575,12 +629,14 @@ fn stream_body( running.clone(), drop_pct, )?; + let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::(2); + spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?; // Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames, // to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds). let perf = crate::config::config().perf; - let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) = - (0u128, 0u128, 0u128, 0u128, 0usize, 0u32); + let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut uniq) = + (0u128, 0u128, 0u128, 0u128, 0u32); // Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors // for p50/p99, the goodput bytes queued to the sender this window, the previous window's // dropped-frame count for delta computation, and the registration id cached on the first sample. @@ -592,7 +648,6 @@ fn stream_body( let mut sid: Option = None; let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec, Vec, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); - let mut bytes_win: u64 = 0; let mut last_dropped_batches: u64 = 0; // Absolute next-frame deadline — the single pacing clock for the loop. let mut next_frame = Instant::now(); @@ -614,6 +669,13 @@ fn stream_body( // ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are. let keyframe_coalesce = frame_interval * 2; let mut last_keyframe: Option = None; + // A frame dropped at the pipeline head (below) breaks the reference chain for the following + // P-frames: the client never receives it, but the encoder advanced its references past it, and — + // packetization being downstream now — a dropped frame consumes no frameIndex for the client to + // detect the gap. So the host re-anchors itself: a drop arms a keyframe on the next iteration, + // routed through the same coalesce gate as client IDR requests so a burst of drops (congestion) + // can't become an IDR storm. + let mut recover_after_drop = false; while running.load(Ordering::SeqCst) { let tick = Instant::now(); @@ -690,7 +752,9 @@ fn stream_body( // Honor a client recovery request. Prefer reference-frame invalidation (the encoder // re-references an older still-valid frame — no costly IDR spike); if the encoder can't // invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe. - let mut want_keyframe = false; + // A prior pipeline drop needs a fresh keyframe to re-anchor the reference chain (see below). + let mut want_keyframe = recover_after_drop; + recover_after_drop = false; if let Some((first, last)) = rfi_range.lock().unwrap().take() { // Prefer reference-frame invalidation when the encoder supports it (no costly IDR // spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe. @@ -723,41 +787,36 @@ fn stream_body( // 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct. let ts = (stream_start.elapsed().as_secs_f64() * 90_000.0) as u32; - let mut batch: Vec> = Vec::new(); + // Drain the encoder's access units (owned buffers) — FEC/packetization runs on the + // packetizer thread, off this loop, so it never serializes behind encode. + let mut aus: Vec<(Vec, FrameType)> = Vec::new(); while let Some(au) = enc.poll().context("encoder poll")? { let ft = if au.keyframe { FrameType::Idr } else { FrameType::P }; - batch.extend(pk.packetize(&au.data, ft, ts)); + aus.push((au.data, ft)); } let t_pkt = tick.elapsed(); - // Hand the frame's packets to the send thread; never block here. A full queue means - // the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding. - let n = batch.len(); - // Goodput this window = bytes actually queued to the sender (a dropped batch never reaches - // the wire, so it's excluded). Summed only when measuring, to keep the idle path free. - let batch_bytes: u64 = if measure { - batch.iter().map(|p| p.len() as u64).sum() - } else { - 0 - }; - if n > 0 { - match batch_tx.try_send(batch) { + // Hand the frame's AUs to the pipeline; never block here. A full queue means the pipeline + // (packetizer, or the paced sender behind it) is behind — drop this frame (FEC/RFI covers the + // client) and keep encoding, so a downstream stall can never cap the encode rate. + if !aus.is_empty() { + match raw_tx.try_send(RawFrame { aus, ts }) { Ok(()) => { sent_batches += 1; - bytes_win += batch_bytes; } Err(std::sync::mpsc::TrySendError::Full(_)) => { dropped_batches += 1; + recover_after_drop = true; // re-anchor the reference chain on the next frame if dropped_batches.is_power_of_two() { - tracing::warn!(dropped_batches, "video: send queue full — frame dropped"); + tracing::warn!(dropped_batches, "video: pipeline queue full — frame dropped"); } } Err(std::sync::mpsc::TrySendError::Disconnected(_)) => { - break; // sender exited (client gone) + break; // packetizer/sender exited (client gone) } } } @@ -765,26 +824,33 @@ fn stream_body( let t_send = tick.elapsed(); let cap_us = t_cap.as_micros(); let enc_us = (t_enc - t_cap).as_micros(); - let pkt_us = (t_pkt - t_enc).as_micros(); - let send_us = (t_send - t_pkt).as_micros(); + // `poll` = drain the encoder's AUs; `enqueue` = hand-off to the pipeline. FEC/packetize + // and the paced send now run on their own threads, off this loop — so both of these + // should be small; if they aren't, the encode loop is being stalled by pipeline + // backpressure (a full queue), which is the signal that a downstream stage can't keep up. + let poll_us = (t_pkt - t_enc).as_micros(); + let enqueue_us = (t_send - t_pkt).as_micros(); mx_cap = mx_cap.max(cap_us); mx_enc = mx_enc.max(enc_us); - mx_pkt = mx_pkt.max(pkt_us); - mx_send = mx_send.max(send_us); - mx_pkts = mx_pkts.max(n); + mx_pkt = mx_pkt.max(poll_us); + mx_send = mx_send.max(enqueue_us); v_cap.push(cap_us as u32); v_enc.push(enc_us as u32); - v_pkt.push(pkt_us as u32); - v_send.push(send_us as u32); + v_pkt.push(poll_us as u32); + v_send.push(enqueue_us as u32); } fps_count += 1; if fps_t.elapsed() >= Duration::from_secs(1) { let secs = fps_t.elapsed().as_secs_f64(); + // Bytes handed to the wire this window, tallied by the packetizer thread (goodput). + let win_bytes = goodput.swap(0, std::sync::atomic::Ordering::Relaxed); if perf { - // Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device - // copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new - // captured frames (vs re-encoded). `pkts`=max packets in one frame (IDR spike). + // Max µs/stage this second on the ENCODE loop: cap=drain channel, enc=submit + // (zero-copy device copy + NVENC), pkt=poll (AU drain), send=enqueue to the pipeline. + // FEC/packetize and the paced send run on their own threads now, so pkt/send here + // should be near-zero — a nonzero value means encode is being stalled by pipeline + // backpressure. `uniq`=new captured frames (vs re-encoded). tracing::info!( fps = fps_count, uniq, @@ -792,7 +858,6 @@ fn stream_body( pkt_us = mx_pkt, send_us = mx_send, cap_us = mx_cap, - max_pkts = mx_pkts, "video: streaming (perf)" ); } else { @@ -805,7 +870,7 @@ fn stream_body( } // Web-console capture: build the aggregated sample. The host send side exposes no // receiver-side packet loss / FEC-recovery / send-buffer EAGAIN counters, so those stay - // 0 (not fabricated); `frames_dropped` is the per-frame send-queue overflow delta. + // 0 (not fabricated); `frames_dropped` is the per-frame pipeline-queue overflow delta. if stats.is_armed() { let session_id = *sid.get_or_insert_with(|| { stats.register_session( @@ -844,7 +909,7 @@ fn stream_body( ], fps: (uniq as f64 / secs) as f32, repeat_fps: (fps_count.saturating_sub(uniq) as f64 / secs) as f32, - mbps: (bytes_win as f64 * 8.0 / secs / 1_000_000.0) as f32, + mbps: (win_bytes as f64 * 8.0 / secs / 1_000_000.0) as f32, bitrate_kbps: cfg.bitrate_kbps, frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32, packets_dropped: 0, @@ -857,13 +922,11 @@ fn stream_body( mx_enc = 0; mx_pkt = 0; mx_send = 0; - mx_pkts = 0; uniq = 0; v_cap.clear(); v_enc.clear(); v_pkt.clear(); v_send.clear(); - bytes_win = 0; last_dropped_batches = dropped_batches; fps_count = 0; fps_t = Instant::now(); From 62e0367f4b02f01feafc3f7fb61f9f1e7d94af57 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 13:53:54 +0000 Subject: [PATCH 25/40] feat(punktfunk1): configurable data-plane UDP port (--data-port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native data plane used a random ephemeral UDP port (hole-punched), which a strict firewall can't pre-open — so remote clients behind one couldn't connect. Add an optional fixed data port: - `Punktfunk1Options`/`NativeServe` gain `data_port`; `bind_data_socket` binds the fixed port (→ direct, no hole-punch) or falls back to a random port + hole-punch when unset or the fixed port is busy (a concurrent session already holds it). - `UdpTransport::from_socket`/`from_socket_punch` adopt an already-bound socket, so the host keeps the SAME data socket from handshake through streaming — no drop-then-rebind window in which a concurrent session could steal a fixed port. - `main.rs` wires the CLI flag through to `NativeServe`. - Firewall docs updated (troubleshooting.md + apt/pacman/bazzite READMEs): control plane is the fixed UDP 9777; the data plane is a separate random port that usually needs no rule, with the fixed-port option for strict firewalls. Unit-tested: default random+hole-punch, and fixed-port-then-fallback-when-busy. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-core/src/transport/udp.rs | 20 +++- crates/punktfunk-host/src/main.rs | 29 +++++ crates/punktfunk-host/src/punktfunk1.rs | 126 +++++++++++++++++---- docs-site/content/docs/troubleshooting.md | 51 ++++++++- packaging/arch/README.md | 19 +++- packaging/bazzite/README.md | 11 +- packaging/debian/README.md | 19 +++- 7 files changed, 238 insertions(+), 37 deletions(-) diff --git a/crates/punktfunk-core/src/transport/udp.rs b/crates/punktfunk-core/src/transport/udp.rs index 30843b3..0589edd 100644 --- a/crates/punktfunk-core/src/transport/udp.rs +++ b/crates/punktfunk-core/src/transport/udp.rs @@ -416,7 +416,14 @@ impl UdpTransport { /// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the /// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract. pub fn connect(local: &str, peer: &str) -> std::io::Result { - let socket = UdpSocket::bind(local)?; + Self::from_socket(UdpSocket::bind(local)?, peer) + } + + /// Adopt an already-bound socket for the data plane: `connect` it to `peer`, tune buffers + + /// QoS, go non-blocking. Lets the host bind the data port up front (e.g. a fixed `--data-port`) + /// and keep the *same* socket from handshake through streaming — no drop-then-rebind window in + /// which a concurrent session could steal a fixed port. + pub fn from_socket(socket: UdpSocket, peer: &str) -> std::io::Result { socket.connect(peer)?; super::qos::grow_socket_buffers(&socket); // The native data plane is video-dominant — tag it as the video class (opt-in via @@ -438,7 +445,16 @@ impl UdpTransport { fallback_peer: &str, punch_timeout: std::time::Duration, ) -> std::io::Result<(Self, bool)> { - let socket = UdpSocket::bind(local)?; + Self::from_socket_punch(UdpSocket::bind(local)?, fallback_peer, punch_timeout) + } + + /// [`connect_via_punch`](Self::connect_via_punch) on an already-bound socket — see + /// [`from_socket`](Self::from_socket) for why the host binds the data port up front. + pub fn from_socket_punch( + socket: UdpSocket, + fallback_peer: &str, + punch_timeout: std::time::Duration, + ) -> std::io::Result<(Self, bool)> { socket.set_read_timeout(Some(punch_timeout))?; let deadline = std::time::Instant::now() + punch_timeout; let mut buf = [0u8; 64]; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index bb859fd..dc45d41 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -418,6 +418,13 @@ fn real_main() -> Result<()> { allow_pairing: true, pairing_pin: None, paired_store: None, + // Fixed data-plane port: bind it and stream direct (no hole-punch), removing the + // ~2.5 s punch-timeout on a firewalled host. Default (absent) = a random port + + // hole-punch. Also honors PUNKTFUNK_DATA_PORT. + data_port: get("--data-port") + .map(str::to_string) + .or_else(|| std::env::var("PUNKTFUNK_DATA_PORT").ok()) + .and_then(|s| s.parse().ok()), }) } // Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point. @@ -501,6 +508,12 @@ fn input_test() -> Result<()> { fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> { let mut opts = mgmt::Options::default(); let mut native_port: u16 = 9777; // the native plane always runs now + // Fixed data-plane UDP port: `Some(p)` binds p and streams direct (no hole-punch, no ~2.5 s + // punch-timeout on a firewalled host); `None` (default) = a random port + hole-punch. Env + // default, `--data-port` overrides. + let mut data_port: Option = std::env::var("PUNKTFUNK_DATA_PORT") + .ok() + .and_then(|s| s.parse().ok()); let mut open = false; let mut gamestream = false; // Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so @@ -541,6 +554,13 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ .parse() .map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))? } + "--data-port" => { + data_port = Some( + next()? + .parse() + .map_err(|_| anyhow::anyhow!("bad --data-port (want a port number)"))?, + ) + } // Opt into the GameStream/Moonlight-compat planes (off by default — they carry the // inherent on-path #5/#9 weaknesses; only for a trusted LAN). "--gamestream" | "--moonlight" => gamestream = true, @@ -576,6 +596,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ // Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than // assuming the default). `opts.bind.port()` is the real port even if the operator moved it. mgmt_port: opts.bind.port(), + data_port, }; Ok((opts, native, gamestream)) } @@ -703,6 +724,10 @@ SERVE OPTIONS: reuse, security-review #5/#9); enable only on a TRUSTED LAN --native no-op (the native punktfunk/1 plane always runs in `serve` now) --native-port native QUIC port (default 9777) + --data-port pin the per-session video data plane to this fixed UDP port and + stream direct (no hole-punch) — open exactly this port in a host + firewall to avoid the ~2.5 s punch-timeout. Default (unset) or + PUNKTFUNK_DATA_PORT: a random port + hole-punch (crosses NAT) --open disable mandatory native pairing (default: pairing REQUIRED — an open host any LAN device can stream from is insecure) @@ -714,6 +739,10 @@ PUNKTFUNK1-HOST OPTIONS: --max-sessions exit after N sessions; 0 = serve forever (default: 0) --max-concurrent stream at most N sessions at once (NVENC bound); overflow waits in the accept queue; 0 = unlimited (default: 4) + --data-port pin the video data plane to this fixed UDP port and stream direct + (no hole-punch; open exactly this port to skip the ~2.5 s punch- + timeout). Default or PUNKTFUNK_DATA_PORT: random port + hole-punch. + A fixed port fits one session; concurrent ones fall back to random --allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise pair=optional. Default: pairing REQUIRED — the host rejects unpaired clients and logs a 4-digit pairing PIN at startup; diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 8feba5a..a8485f9 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -75,6 +75,35 @@ pub struct Punktfunk1Options { pub pairing_pin: Option, /// Paired-clients store path override (tests); `None` = the default config path. pub paired_store: Option, + /// Fixed data-plane UDP port. `None`/`Some(0)` (default): bind a random ephemeral port and + /// **hole-punch** — wait ~2.5 s for the client's punch, then fall back to its reported address + /// (traverses NAT / a stateful inter-VLAN firewall with no forwarded port, at the cost of the + /// punch-timeout on a firewall that drops the punch). `Some(p)`: bind that fixed port and + /// stream **directly** to the client's reported address with no punch-wait — for a host whose + /// data port is fixed + firewall-opened/forwarded, this removes the punch-timeout delay. A + /// fixed port only fits one data plane at a time, so a concurrent session finding it busy + /// falls back to random + hole-punch (see [`bind_data_socket`]). + pub data_port: Option, +} + +/// Bind the per-session data-plane UDP socket, honoring [`Punktfunk1Options::data_port`]. Returns +/// `(socket, direct)`: `direct = true` (a successfully-bound fixed port) means "stream straight to +/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means +/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind +/// window in which a concurrent session could steal a fixed port. +fn bind_data_socket(data_port: Option) -> std::io::Result<(std::net::UdpSocket, bool)> { + if let Some(p) = data_port.filter(|p| *p != 0) { + match std::net::UdpSocket::bind(("0.0.0.0", p)) { + Ok(sock) => return Ok((sock, true)), + Err(e) => tracing::warn!( + data_port = p, + error = %e, + "fixed --data-port is busy (a concurrent session already holds it?) — \ + falling back to a random port + hole-punch for this session" + ), + } + } + Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false)) } /// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API. @@ -143,6 +172,9 @@ pub(crate) struct NativeServe { /// The management API's TCP port, advertised over mDNS so a client browses the game library on /// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port). pub mgmt_port: u16, + /// Fixed data-plane UDP port (`--data-port` / `PUNKTFUNK_DATA_PORT`); see + /// [`Punktfunk1Options::data_port`]. `None` = random port + hole-punch (the default). + pub data_port: Option, } /// Options for the native host when the unified `serve --native` runs it: real virtual capture, @@ -165,6 +197,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { allow_pairing: false, pairing_pin: None, paired_store: None, + data_port: cfg.data_port, } } @@ -656,6 +689,7 @@ async fn serve_session( let source = opts.source; let frames = opts.frames; + let data_port = opts.data_port; let handshake = async { let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; anyhow::ensure!( @@ -846,10 +880,12 @@ async fn serve_session( "encode chroma" ); - // Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport). - let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; - let udp_port = probe.local_addr()?.port(); - drop(probe); + // Reserve the data-plane UDP socket up front and HOLD it through streaming (no + // bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed + // `--data-port` yields `direct = true` (stream straight to the client's reported address, + // no punch-wait); otherwise a random ephemeral port + hole-punch. + let (data_sock, direct) = bind_data_socket(data_port)?; + let udp_port = data_sock.local_addr()?.port(); let mut key = [0u8; 16]; rand::thread_rng().fill_bytes(&mut key); @@ -909,9 +945,9 @@ async fn serve_session( let start = Start::decode(&io::read_msg(&mut recv).await?) .map_err(|e| anyhow!("Start decode: {e:?}"))?; - Ok::<_, anyhow::Error>((hello, welcome, udp_port, start, compositor)) + Ok::<_, anyhow::Error>((hello, welcome, udp_port, data_sock, direct, start, compositor)) }; - let (hello, welcome, udp_port, start, compositor) = + let (hello, welcome, udp_port, data_sock, direct, start, compositor) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) .await .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; @@ -1233,29 +1269,41 @@ async fn serve_session( .unwrap_or_else(|| conn.remote_address().ip().to_string()); let result: Result<()> = async { tokio::task::spawn_blocking(move || -> Result<()> { - // Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED - // source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host - // can be on different subnets; control + side planes ride the client-initiated QUIC, but - // the raw video UDP needs the client to open the path first). Falls back to the - // client-reported address for clients that don't punch (flat-LAN, unchanged). - let (transport, punched) = match UdpTransport::connect_via_punch( - &format!("0.0.0.0:{udp_port}"), - &client_udp.to_string(), - std::time::Duration::from_millis(2500), - ) { + // Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly + // for the client's punch, then stream to its OBSERVED source, so video traverses a + // NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated + // QUIC, but the raw video UDP needs the client to open the path first); falls back to + // the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed + // `--data-port` (`direct`), skip the punch-wait and stream straight to the reported + // address — the operator declared a reachable, firewall-opened port, so there's no + // punch-timeout to pay. (Direct trusts the reported port: it can't cross a client-side + // NAT that remaps it.) + let bound = if direct { + UdpTransport::from_socket(data_sock, &client_udp.to_string()).map(|t| (t, false)) + } else { + UdpTransport::from_socket_punch( + data_sock, + &client_udp.to_string(), + std::time::Duration::from_millis(2500), + ) + }; + let (transport, punched) = match bound { Ok(v) => v, Err(e) => { // Surface the failure here directly: a data-plane bind error would otherwise be // reported only after teardown (and a teardown stall could swallow it entirely). - tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket bind/hole-punch failed"); + tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket setup failed"); return Err(anyhow::Error::new(e)).context("bind data plane"); } }; tracing::info!( %client_udp, + udp_port, + direct, punched, - "data plane bound (punched=true → streaming to the client's observed source; \ - false → no hole-punch seen, using the reported address)" + "data plane bound (direct=true → fixed --data-port, streaming to the reported \ + address with no hole-punch; else punched=true → the client's observed source, \ + false → no punch seen, the reported address)" ); let mut session = Session::new(cfg, Box::new(transport)) .map_err(|e| anyhow!("host session: {e:?}"))?; @@ -3650,6 +3698,43 @@ mod tests { assert!(adapt_fec(u32::MAX) <= FEC_MAX); } + #[test] + fn data_socket_defaults_to_random_hole_punch() { + // No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the + // caller hole-punches. + for req in [None, Some(0)] { + let (sock, direct) = bind_data_socket(req).expect("bind random data socket"); + assert!(!direct, "req={req:?} must hole-punch, not stream direct"); + assert_ne!(sock.local_addr().unwrap().port(), 0); + } + } + + #[test] + fn data_socket_fixed_binds_direct_then_falls_back_when_busy() { + // Learn a currently-free port (bind :0, read it, drop — the same reserve-then-rebind the + // host itself uses; a race here would only make the assert below flaky, not wrong). + let free = std::net::UdpSocket::bind("0.0.0.0:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + + // A free fixed port binds exactly it, in DIRECT mode (no hole-punch). + let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket"); + assert!(direct, "a fixed --data-port must stream direct"); + assert_eq!(held.local_addr().unwrap().port(), free); + + // While it's held, a second session on the same fixed port can't bind it → it must fall + // back to a random port + hole-punch rather than fail (so concurrency never regresses). + let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back"); + assert!(!direct2, "a busy fixed port must fall back to hole-punch"); + assert_ne!( + fallback.local_addr().unwrap().port(), + free, + "the fallback must not reuse the busy fixed port" + ); + } + #[test] fn compositor_resolution_precedence() { use crate::vdisplay::Compositor::*; @@ -3847,6 +3932,7 @@ mod tests { allow_pairing: false, pairing_pin: None, paired_store: None, + data_port: None, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); @@ -4041,6 +4127,7 @@ mod tests { allow_pairing: false, pairing_pin: None, paired_store: None, // unused: the shared `np` IS the store handle + data_port: None, }, 0, // no mgmt API in this test → advertise no `mgmt` mDNS port np_host, @@ -4139,6 +4226,7 @@ mod tests { allow_pairing: false, pairing_pin: Some("4321".into()), paired_store: Some(test_paired_path()), + data_port: None, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 5238f00..94792e4 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -10,11 +10,52 @@ description: Common problems setting up or using a punktfunk host, and how to fi - Host and client must be on the **same network/subnet**. Discovery uses mDNS, which doesn't cross routed subnets or most VPNs-without-multicast. As a fallback, add the host by **IP address** in your client. -- A firewall on the host can block it. The native protocol's control plane uses UDP port **9777**. The - per-session **data plane** uses an *ephemeral* UDP port negotiated at connect time (currently - random) — for a strict firewall, open a UDP range or move the data port. GameStream/Moonlight uses - TCP **47984/47989/48010** + UDP **47998–48010** + ENet UDP **47999**. Allow them on the host's - firewall. +- A firewall on the host can block it. The native protocol's **control plane** is a fixed UDP port, + **9777** — open this one. The per-session **data plane** rides a *separate, random* UDP port and + usually needs **no** firewall rule (see [Video is slow to start, or fails across + subnets](#video-is-slow-to-start-or-fails-across-subnets) for why, and the one case where opening it + helps). GameStream/Moonlight (only with `--gamestream`) uses TCP **47984/47989/48010** + UDP + **47998–48010** (video/FEC 47998, ENet control 47999, audio 48000) + mDNS UDP **5353**. Allow those + on the host's firewall. + +## Video is slow to start, or fails across subnets + +The native **data plane** (the raw UDP that carries video, separate from the 9777 control plane) uses +a **random, per-session UDP port** — the host binds `0.0.0.0:0`, then tells the client which port it +got during the connect handshake. There is no fixed data port. + +Video flows host → client, but the **client sends the first packet**: a small *hole-punch* datagram to +that port. This is deliberate. It lets the host learn the client's real (possibly NAT-translated) +source address and stream back to it, so a session can cross a NAT or a stateful inter-VLAN firewall +**without** a forwarded data port. What it means for a host firewall: + +- **Same LAN, no host firewall (or the port allowed):** the punch arrives immediately and video starts + at once. Nothing to configure. +- **Same LAN, host firewall that denies inbound** (ufw/nftables/firewalld default): the punch is + dropped, so the host waits **~2.5 s**, then falls back to the address the client reported and streams + anyway — a stateful firewall admits the return traffic because the host sent first. **Net effect: it + works, but each session takes ~2.5 s longer to start.** That slow start is the symptom of a + data-plane rule you're missing. +- **Across subnets / NAT:** the same punch-then-fallback applies, as long as the host's outbound video + can reach the client (the path's stateful firewall then admits the return). If the host itself is + behind NAT reached only via a forwarded control port, the data path may not establish — this is the + case a fixed, forwardable data port would solve. + +To remove the ~2.5 s fallback delay, **pin the data port** with `--data-port` (or the +`PUNKTFUNK_DATA_PORT` env in `host.env`) and open exactly that one port. The host then binds that +fixed port, skips the punch-wait, and streams straight to the client — no timeout to pay: + +```sh +punktfunk-host serve --data-port 9778 # or PUNKTFUNK_DATA_PORT=9778 in host.env +sudo ufw allow 9778/udp # open exactly that one port +``` + +Two caveats. A fixed data port serves **one session at a time**; a second concurrent session finds it +busy and transparently falls back to a random port + hole-punch (logged). And `--data-port` streams +to the client's *reported* address, so use it only where that address is reachable — a flat LAN, or a +port-forward that doesn't remap the client's source. Leave it **off** (the default) to keep the +NAT-crossing hole-punch. On a normal single-LAN setup you can also just leave the data port closed and +accept the one-time ~2.5 s punch-timeout, or not run a host firewall on a trusted LAN at all. ## `nvidia-smi` says it can't communicate with the driver diff --git a/packaging/arch/README.md b/packaging/arch/README.md index e3880b8..f21df2d 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -142,8 +142,16 @@ so it's a much lighter sysext than the host. If the host box runs a firewall, open the ports it listens on. The **native `punktfunk/1`** plane: - **QUIC control plane: UDP 9777** (`serve --native-port N` to change). -- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to - open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one). +- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and + tells the client which port it got. Video flows host → client, but the **client sends the first + packet** (a hole-punch), so the host learns the client's real source and streams back — this + traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound + firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a + stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay, + pin it with **`serve --data-port `** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed + port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one + session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the + client's reported address to be reachable (flat LAN / a non-remapping port-forward). And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with `serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these: @@ -166,7 +174,9 @@ sudo ufw allow 9777/udp # punktfunk/1 control pl sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp sudo ufw allow 47998:48010/udp sudo ufw allow 5353/udp -# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it. +# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches +# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port +# 9778` and `ufw allow 9778/udp`. ``` With raw `nftables` (add to your `inet filter input` chain): @@ -175,7 +185,8 @@ With raw `nftables` (add to your `inet filter input` chain): udp dport 9777 accept # punktfunk/1 control plane tcp dport { 47984, 47989, 48010 } accept udp dport { 47998-48010, 5353 } accept -# plus the ephemeral punktfunk/1 data port (a reserved UDP range). +# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s +# fallback). Pin it with `serve --data-port ` to open exactly one instead. ``` ## Files diff --git a/packaging/bazzite/README.md b/packaging/bazzite/README.md index f189df2..c704489 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -361,9 +361,14 @@ sudo firewall-cmd --reload default unit): - **QUIC control plane: UDP 9777** (default `--port`; change with `--port N`). -- **Data plane: an *ephemeral* UDP port** — `punktfunk1-host` binds `0.0.0.0:0` and tells the client which - port it got, so there is **no fixed data port to open**. For a restrictive firewall you'd need to - allow the ephemeral UDP range; the repo does not pin one. +- **Data plane: a separate UDP port** — by default *random* (`0.0.0.0:0`), so there is **no fixed + port to open**. Video flows host → client, but the client sends the first packet (a hole-punch): if + firewalld drops it, the host waits ~2.5 s and falls back to the client-reported address and streams + anyway, so you normally **leave the data port closed**. To skip that ~2.5 s fallback, pin it with + `serve --data-port ` (or `PUNKTFUNK_DATA_PORT`) and open exactly that one port with + `firewall-cmd --add-port=/udp`. A fixed port serves one session at a time (concurrent ones + fall back to random + hole-punch) and streams to the client's reported address (flat LAN / + non-remapping forward only). ```sh # Only if you run `punktfunk1-host`: diff --git a/packaging/debian/README.md b/packaging/debian/README.md index f5e48d3..a144b84 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -55,8 +55,16 @@ journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' Open the ports the host listens on. The **native `punktfunk/1`** plane: - **QUIC control plane: UDP 9777** (`serve --native-port N` to change). -- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to - open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one). +- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and + tells the client which port it got. Video flows host → client, but the **client sends the first + packet** (a hole-punch), so the host learns the client's real source and streams back — this + traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound + firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a + stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay, + pin it with **`serve --data-port `** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed + port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one + session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the + client's reported address to be reachable (flat LAN / a non-remapping port-forward). And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with `serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these: @@ -79,7 +87,9 @@ sudo ufw allow 9777/udp # punktfunk/1 control pl sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp sudo ufw allow 47998:48010/udp sudo ufw allow 5353/udp -# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it. +# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches +# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port +# 9778` and `ufw allow 9778/udp`. ``` With raw `nftables` (add to your `inet filter input` chain): @@ -88,7 +98,8 @@ With raw `nftables` (add to your `inet filter input` chain): udp dport 9777 accept # punktfunk/1 control plane tcp dport { 47984, 47989, 48010 } accept udp dport { 47998-48010, 5353 } accept -# plus the ephemeral punktfunk/1 data port (a reserved UDP range). +# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s +# fallback). Pin it with `serve --data-port ` to open exactly one instead. ``` ## Updates From 8986667b78539e9e5cd1fa26a36a0bd731b8c681 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 14:38:40 +0000 Subject: [PATCH 26/40] =?UTF-8?q?feat(web):=20full=20virtual-display=20con?= =?UTF-8?q?fig=20surface=20=E2=80=94=20one-click=20presets=20+=20every=20a?= =?UTF-8?q?xis=20editable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Virtual displays card previously only exposed keep_alive/topology/max_displays as editable custom fields; conflict/identity/layout (enforced since Stages 3-5) had no controls, and the presets weren't surfaced as one-click options. Rework the card so the whole policy is configurable WITHOUT any client connected: - Presets front-and-center: each of the five (default/shared-desktop/hotdesk/workstation/ gaming-rig) is a one-click row showing its story AND what it sets (keep-alive · topology · conflict · identity badges), highlighting the active one. A click applies it immediately. gaming-rig stays disabled + "coming soon" (keep_alive: forever isn't cross-platform yet). - Custom mode reveals EVERY axis editably — keep-alive, topology, conflict, identity, layout, max-displays — seeded from the current effective behavior, with a Save button. A reusable `Choice` button-group + a tolerant `tr()` label lookup keep it tidy. - The live-display list + multi-monitor arrangement table stay below (they need a live session); the settings above work standalone. - en+de i18n for the new controls; refreshed the effective-preview row to show all axes. web tsc + vite build + biome-lint green. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/messages/de.json | 20 ++ web/messages/en.json | 20 ++ web/src/sections/Host/DisplayCard.tsx | 472 ++++++++++++++++---------- 3 files changed, 342 insertions(+), 170 deletions(-) diff --git a/web/messages/de.json b/web/messages/de.json index 19fb54b..353ecfe 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -80,6 +80,26 @@ "display_arrange": "Anzeigen anordnen", "display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.", "display_arrange_save": "Anordnung speichern", + "display_custom_desc": "Jede Option selbst festlegen.", + "display_preset_current": "Aktiv", + "display_preset_soon": "in Kürze", + "display_keep_alive_help": "Wie lange eine Anzeige (und bei gamescope ihr Spiel) nach dem Trennen bestehen bleibt. 0 = sofort abbauen.", + "display_topology_help": "Was mit den physischen Monitoren des Hosts während des Streamings geschieht.", + "display_conflict": "Wenn ein weiterer Client verbindet", + "display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.", + "display_conflict_separate": "Eigene Anzeige", + "display_conflict_steal": "Übernehmen", + "display_conflict_join": "Ansicht teilen", + "display_conflict_reject": "Besetzt — ablehnen", + "display_identity": "Client-Identität", + "display_identity_help": "Jedem Client eine stabile Anzeige geben, damit der Desktop seine Monitor-Einstellungen merkt (z. B. Skalierung).", + "display_identity_shared": "Geteilt", + "display_identity_per_client": "Pro Client", + "display_identity_per_client_mode": "Pro Client + Auflösung", + "display_layout_mode": "Multi-Monitor-Anordnung", + "display_layout_help": "Wie mehrere Anzeigen auf dem Desktop angeordnet werden. Manuell nutzt die Anordnungstabelle unten (ab 2 Anzeigen).", + "display_layout_auto_row": "Automatisch (nebeneinander)", + "display_layout_manual": "Manuell", "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 e0e50e0..f7b1442 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -80,6 +80,26 @@ "display_arrange": "Arrange displays", "display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.", "display_arrange_save": "Save arrangement", + "display_custom_desc": "Set every option yourself.", + "display_preset_current": "Active", + "display_preset_soon": "coming soon", + "display_keep_alive_help": "How long a display (and, on gamescope, its game) survives after the client disconnects. 0 = tear down immediately.", + "display_topology_help": "What happens to the host's physical monitors while streaming.", + "display_conflict": "When another client connects", + "display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.", + "display_conflict_separate": "Own display", + "display_conflict_steal": "Take over", + "display_conflict_join": "Share view", + "display_conflict_reject": "Busy — reject", + "display_identity": "Per-client identity", + "display_identity_help": "Give each client a stable display so the desktop remembers its per-monitor settings (e.g. scaling).", + "display_identity_shared": "Shared", + "display_identity_per_client": "Per client", + "display_identity_per_client_mode": "Per client + resolution", + "display_layout_mode": "Multi-monitor layout", + "display_layout_help": "How several displays are arranged on the desktop. Manual uses the arrangement table below (with 2+ displays).", + "display_layout_auto_row": "Auto (side by side)", + "display_layout_manual": "Manual", "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 index 13e50d6..35e034a 100644 --- a/web/src/sections/Host/DisplayCard.tsx +++ b/web/src/sections/Host/DisplayCard.tsx @@ -10,15 +10,18 @@ import { useSetDisplayLayout, useSetDisplaySettings, } from "@/api/gen/display/display"; -import type { ApiDisplayInfo } from "@/api/gen/model"; -import { ApiError } from "@/api/fetcher"; import type { + ApiDisplayInfo, DisplayPolicy, EffectivePolicy, + Identity, KeepAlive, + LayoutMode, + ModeConflict, Preset, Topology, } from "@/api/gen/model"; +import { ApiError } from "@/api/fetcher"; import { QueryState } from "@/components/query-state"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -27,26 +30,27 @@ 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. + * Container: the host's virtual-display management policy (design/display-management.md). Lets the + * operator pick a one-click preset OR set every option by hand — all WITHOUT any client connected + * (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement + * table below act on whatever is currently streaming. */ 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. + // Local edit buffer, seeded once from the server and re-seeded after every successful apply. const [draft, setDraft] = useState(null); useEffect(() => { if (q.data && draft === null) setDraft(q.data.settings); }, [q.data, draft]); - const onSave = () => { - if (!draft) return; + // Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on + // the next connect; a live session keeps the display it opened on. + const apply = (policy: DisplayPolicy) => save.mutate( - { data: draft }, + { data: policy }, { onSuccess: (res) => { setDraft(res.settings); @@ -54,7 +58,6 @@ export const DisplaySection: FC = () => { }, }, ); - }; return ( @@ -69,7 +72,7 @@ export const DisplaySection: FC = () => { draft={draft} setDraft={setDraft} presets={q.data.presets} - onSave={onSave} + apply={apply} busy={save.isPending} error={apiErrorMessage(save.error)} /> @@ -81,6 +84,265 @@ export const DisplaySection: FC = () => { ); }; +/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */ +const PRESET_ORDER = [ + "default", + "shared-desktop", + "hotdesk", + "workstation", + "gaming-rig", + "custom", +] as const; + +const DisplayForm: FC<{ + draft: DisplayPolicy; + setDraft: (p: DisplayPolicy) => void; + presets: { id: string; summary: string; fields: EffectivePolicy }[]; + apply: (p: DisplayPolicy) => void; + busy: boolean; + error?: string; +}> = ({ draft, setDraft, presets, apply, busy, error }) => { + const preset: Preset = draft.preset ?? "custom"; + const isCustom = preset === "custom"; + + // The Custom fields (defaults filled): the edit buffer when preset === "custom", and what a + // preset→Custom switch is seeded from, so you customize starting from the current behavior. + const customFields: EffectivePolicy = { + keep_alive: draft.keep_alive ?? { mode: "duration", seconds: 10 }, + topology: draft.topology ?? "auto", + mode_conflict: draft.mode_conflict ?? "separate", + identity: draft.identity ?? "per-client", + layout: draft.layout ?? { mode: "auto-row", positions: {} }, + max_displays: draft.max_displays ?? 4, + }; + const effective: EffectivePolicy = + (isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ?? customFields; + + // The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current + // effective behavior (nothing changes until you Save). + const pickPreset = (id: string) => { + if (id === "custom") { + setDraft({ + version: 1, + preset: "custom", + keep_alive: effective.keep_alive, + topology: effective.topology, + mode_conflict: effective.mode_conflict, + identity: effective.identity, + layout: effective.layout, + max_displays: effective.max_displays, + }); + } else { + apply({ ...draft, preset: id as Preset }); + } + }; + + const ka = customFields.keep_alive; + const secondsValue = ka.mode === "duration" ? ka.seconds : 300; + + return ( +
    + {/* One-click presets */} +
    + +
    + {PRESET_ORDER.map((id) => { + const p = presets.find((x) => x.id === id); + const fields = id === "custom" ? undefined : p?.fields; + const summary = id === "custom" ? m.display_custom_desc() : p?.summary; + const selected = preset === id; + const soon = DISABLED_PRESETS.has(id); + const cls = [ + "w-full rounded-md border p-3 text-left transition-colors", + selected ? "border-primary ring-1 ring-primary" : "hover:bg-muted/50", + soon ? "opacity-60" : "", + ].join(" "); + return ( + + ); + })} +
    +
    + + {/* Custom: every option by hand */} + {isCustom && ( +
    +
    + +
    + + + setDraft({ + ...draft, + keep_alive: { + mode: "duration", + seconds: Math.max(0, Number(e.target.value) || 0), + }, + }) + } + /> + + {m.display_keep_alive_seconds()} + +
    +

    {m.display_keep_alive_help()}

    +
    + + setDraft({ ...draft, topology: v as Topology })} + /> + setDraft({ ...draft, mode_conflict: v as ModeConflict })} + /> + setDraft({ ...draft, identity: v as Identity })} + /> + + setDraft({ + ...draft, + layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} }, + }) + } + /> + +
    + + + setDraft({ + ...draft, + max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> +
    + + +
    + )} + + {/* What's in force right now */} +
    + {m.display_effective()}: + {fmtKeepAlive(effective.keep_alive)} + {tr(TOPOLOGY_LABEL, effective.topology)} + {tr(CONFLICT_LABEL, effective.mode_conflict)} + {tr(IDENTITY_LABEL, effective.identity)} + {tr(LAYOUT_LABEL, effective.layout.mode)} + {`${effective.max_displays}×`} +
    + +

    {m.display_pending_note()}

    + {error &&

    {error}

    } +
    + ); +}; + +/** A labeled row of mutually-exclusive option buttons (topology / conflict / identity / layout). */ +const Choice: FC<{ + label: string; + help?: string; + value: string; + options: readonly string[]; + labels: Record string>; + disabled: boolean; + onPick: (v: string) => void; +}> = ({ label, help, value, options, labels, disabled, onPick }) => ( +
    + +
    + {options.map((o) => ( + + ))} +
    + {help &&

    {help}

    } +
    +); + /** * The host's live/kept virtual displays, polled from `/display/state`, each with a Release button * for lingering/pinned ones (active displays can't be released — that's session control). @@ -179,8 +441,7 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => return (
    - {d.mode}{" "} - #{slot} + {d.mode} #{slot}