diff --git a/README.md b/README.md index 519f8c1..d118007 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C - **Your device's exact mode.** For each client that connects, the host spins up a virtual display sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No letterboxing, no scaling, no rearranging your real monitors. +- **Displays you configure, not just create.** Keep a game's display (and the game) alive across + disconnects so a reconnect drops straight back in; make the stream your sole desktop or extend + alongside your monitors; let several devices become monitors of one desktop; keep each client's + scaling. One-click presets in the console — a dedicated couch box, a shared desktop, a multi-monitor + workstation. See [Virtual displays](docs-site/content/docs/virtual-displays.md). - **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs; on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own diff --git a/api/openapi.json b/api/openapi.json index 96610d4..0abb080 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,224 @@ } } }, + "/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": [ + "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": [ + "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` (the gaming-rig preset) is\nhonored (the display is Pinned; free it via `POST /display/release`).", + "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": "Malformed policy body", + "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/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": [ @@ -1601,6 +1819,99 @@ "av1" ] }, + "ApiDisplayInfo": { + "type": "object", + "description": "One live or kept virtual display.", + "required": [ + "slot", + "backend", + "mode", + "state", + "sessions", + "group", + "display_index", + "x", + "y", + "topology" + ], + "properties": { + "backend": { + "type": "string", + "description": "Backend name (`pf-vdisplay`, `kwin`, …)." + }, + "client": { + "type": [ + "string", + "null" + ], + "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", + "null" + ], + "format": "int64", + "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`." + }, + "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`." + }, + "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`." + } + } + }, "ApiError": { "type": "object", "description": "Error envelope for every non-2xx response.", @@ -1909,6 +2220,146 @@ } } }, + "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`].", + "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. 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", + "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." + } + } + }, + "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`].", + "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 +2550,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 +2635,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.", @@ -2128,13 +2671,20 @@ "paired_clients", "native_paired_clients", "pin_pending", - "pending_approvals" + "pending_approvals", + "kept_displays" ], "properties": { "audio_streaming": { "type": "boolean", "description": "True while the audio stream thread is running." }, + "kept_displays": { + "type": "integer", + "format": "int32", + "description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.", + "minimum": 0 + }, "native_paired_clients": { "type": "integer", "format": "int32", @@ -2242,6 +2792,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 +2999,88 @@ } } }, + "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." + } + } + }, + "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).", @@ -2740,6 +3382,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 +3415,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/clients/probe/src/main.rs b/clients/probe/src/main.rs index 99c47cf..998e9a4 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -73,6 +73,14 @@ struct Args { /// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs /// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back. rich_input_test: bool, + /// `--quit` — close the connection with the deliberate-quit code (`QUIT_CLOSE_CODE`) at end of + /// stream, so the host tears its virtual display down immediately (skips keep-alive linger). A + /// bare exit closes with code 0 → the host lingers for a reconnect. Tests the #2 quit path. + quit: bool, + /// `--seconds N` — cap the receive loop at N seconds, then end the session gracefully (reach the + /// `conn.close`). Without it the loop runs to the 120s cap. Lets a test bound a live-host stream so + /// the client-initiated close (with/without `--quit`) fires promptly. + seconds: Option, pin: Option<[u8; 32]>, /// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream. remode: Option<(Mode, u32)>, @@ -211,6 +219,8 @@ fn parse_args() -> Args { mic_burst: argv.iter().any(|a| a == "--mic-burst"), touch_test: argv.iter().any(|a| a == "--touch-test"), rich_input_test: argv.iter().any(|a| a == "--rich-input-test"), + quit: argv.iter().any(|a| a == "--quit"), + seconds: get("--seconds").and_then(|s| s.parse().ok()), pin, remode, pair: get("--pair").map(String::from), @@ -1041,6 +1051,9 @@ async fn session(args: Args) -> Result<()> { let mut net_us_v: Vec = Vec::new(); let mut last_rx = std::time::Instant::now(); let started = std::time::Instant::now(); + // Stream-duration cap: `--seconds N`, else the 120s default. Ending the loop here reaches the + // graceful `conn.close` below (with the deliberate-quit code if `--quit`). + let cap_secs = args.seconds.unwrap_or(120); // Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task. let mut last_loss_report = std::time::Instant::now(); let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64); @@ -1076,7 +1089,7 @@ async fn session(args: Args) -> Result<()> { { break; } - if started.elapsed() > std::time::Duration::from_secs(120) + if started.elapsed() > std::time::Duration::from_secs(cap_secs) || last_rx.elapsed() > std::time::Duration::from_secs(8) { break; @@ -1208,7 +1221,18 @@ async fn session(args: Args) -> Result<()> { } } - conn.close(0u32.into(), b"done"); + // `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal + // exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect). + let close_code = if args.quit { + punktfunk_core::quic::QUIT_CLOSE_CODE + } else { + 0 + }; + conn.close(close_code.into(), b"done"); + // Flush the CONNECTION_CLOSE frame before we exit: without this the process can drop the endpoint + // before quinn sends the close, so the host waits out the idle timeout instead of seeing the close + // CODE promptly (deliberate-quit vs. code 0). Bounded so a stuck flush can't hang the probe. + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ep.wait_idle()).await; result } diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 91bf531..c18bf4b 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -179,6 +179,10 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, + /// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC + /// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the + /// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close. + quit: Arc, /// Cumulative count of access units the reassembler gave up on (FEC couldn't recover), mirrored /// from the data-plane pump's `Session`. A client video loop watches this for increases to request /// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss @@ -331,6 +335,7 @@ impl NativeClient { let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); + let quit = Arc::new(AtomicBool::new(false)); let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let probe = Arc::new(Mutex::new(ProbeState::default())); let frames_dropped = Arc::new(AtomicU64::new(0)); @@ -338,6 +343,7 @@ impl NativeClient { let host = host.to_string(); let shutdown_w = shutdown.clone(); + let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); let frames_dropped_w = frames_dropped.clone(); @@ -388,6 +394,7 @@ impl NativeClient { ctrl_tx: ctrl_tx_pump, ready_tx, shutdown: shutdown_w, + quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, frames_dropped: frames_dropped_w, @@ -430,6 +437,7 @@ impl NativeClient { ctrl_tx, probe, shutdown, + quit, worker: Some(worker), frames_dropped, hot_tids, @@ -764,6 +772,15 @@ impl NativeClient { .send(rich) .map_err(|_| PunktfunkError::Closed) } + + /// Signal a **deliberate quit** (a user "stop", not a network drop): the worker closes the QUIC + /// connection with [`crate::quic::QUIT_CLOSE_CODE`] instead of code 0, so the host tears the + /// session's virtual display down immediately and skips the keep-alive linger. Then requests + /// shutdown. A plain `drop` (without this) closes with code 0 → the host lingers for a reconnect. + pub fn disconnect_quit(&self) { + self.quit.store(true, Ordering::SeqCst); + self.shutdown.store(true, Ordering::SeqCst); + } } impl Drop for NativeClient { @@ -802,6 +819,8 @@ struct WorkerArgs { ctrl_tx: tokio::sync::mpsc::UnboundedSender, ready_tx: std::sync::mpsc::Sender>, shutdown: Arc, + /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. + quit: Arc, mode_slot: Arc>, probe: Arc>, frames_dropped: Arc, @@ -838,6 +857,7 @@ async fn worker_main(args: WorkerArgs) { ctrl_tx, ready_tx, shutdown, + quit, mode_slot, probe, frames_dropped, @@ -1210,5 +1230,12 @@ async fn worker_main(args: WorkerArgs) { }) .await; - conn.close(0u32.into(), b"client closed"); + // Deliberate quit (a user "stop") closes with the quit code → the host skips the keep-alive + // linger; a plain drop / disconnect closes with 0 → the host lingers so a reconnect can resume. + let close_code = if quit.load(Ordering::SeqCst) { + crate::quic::QUIT_CLOSE_CODE + } else { + 0 + }; + conn.close(close_code.into(), b"client closed"); } diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index 8126a2d..ff89707 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -122,6 +122,13 @@ pub const VIDEO_CAP_444: u8 = 0x04; /// stage. Purely observability — never changes what the host encodes. pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08; +/// QUIC application error code a punktfunk/1 client closes the control connection with on a +/// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's +/// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the +/// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a +/// reconnect can resume. Shared so host + every client agree on the code. +pub const QUIT_CLOSE_CODE: u32 = 0x51; + /// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST /// advertise this. @@ -1743,20 +1750,31 @@ pub mod endpoint { /// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so /// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false /// close, while a genuinely dead peer is still detected within `MAX_IDLE`. + /// The default control-connection idle timeout (disconnect-detection latency). A vanished client + /// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT + /// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux + /// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`. + pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8); + fn stream_transport() -> Arc { + stream_transport_idle(DEFAULT_IDLE_TIMEOUT) + } + + /// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The + /// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live + /// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't + /// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live + /// sessions down. Active sessions are unaffected either way: video keeps the connection live and + /// the keep-alive holds it open through quiet control periods. + fn stream_transport_idle(idle: std::time::Duration) -> Arc { use std::time::Duration; - // 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its - // session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates - // a fresh virtual monitor (a reused monitor's IddCx swap-chain dies) instead of joining the - // still-lingering old session. Active sessions are unaffected: video keeps the connection live, - // and the 4s keep-alive holds it open through quiet control periods. - const MAX_IDLE: Duration = Duration::from_secs(8); - const KEEP_ALIVE: Duration = Duration::from_secs(4); + let idle = idle.max(Duration::from_secs(1)); + let keep_alive = (idle / 2).min(Duration::from_secs(4)); let mut t = quinn::TransportConfig::default(); t.max_idle_timeout(Some( - quinn::IdleTimeout::try_from(MAX_IDLE).expect("8s is a valid QUIC idle timeout"), + quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"), )); - t.keep_alive_interval(Some(KEEP_ALIVE)); + t.keep_alive_interval(Some(keep_alive)); Arc::new(t) } @@ -1767,23 +1785,36 @@ pub mod endpoint { .map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?; let cert_der = rustls::pki_types::CertificateDer::from(cert.cert); let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); - server_from_der(cert_der, key_der.into(), addr) + server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT) } /// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) — /// the host's long-lived self-signed cert, so the fingerprint clients pin is stable - /// across restarts. + /// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it. pub fn server_with_identity( addr: std::net::SocketAddr, cert_pem: &str, key_pem: &str, + ) -> anyhow_result::Result { + server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT) + } + + /// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the + /// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter = + /// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its + /// keep-alive scales with it so a live session never false-closes. + pub fn server_with_identity_idle( + addr: std::net::SocketAddr, + cert_pem: &str, + key_pem: &str, + idle: std::time::Duration, ) -> anyhow_result::Result { use rustls::pki_types::pem::PemObject; let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes()) .map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?; let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes()) .map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?; - server_from_der(cert_der, key_der, addr) + server_from_der(cert_der, key_der, addr, idle) } /// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the @@ -1796,6 +1827,7 @@ pub mod endpoint { cert_der: rustls::pki_types::CertificateDer<'static>, key_der: rustls::pki_types::PrivateKeyDer<'static>, addr: std::net::SocketAddr, + idle: std::time::Duration, ) -> anyhow_result::Result { let _ = rustls::crypto::ring::default_provider().install_default(); // Client auth is OFFERED but optional: a client that presents its self-signed @@ -1810,7 +1842,7 @@ pub mod endpoint { let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg) .map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?; let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg)); - server_config.transport_config(stream_transport()); // keep-alive — see stream_transport + server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle Ok(quinn::Endpoint::server(server_config, addr)?) } 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/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/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..be2c2ea 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -126,15 +126,57 @@ 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 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 live = st + .launch + .lock() + .unwrap() + .as_ref() + .map(|s| (s.owner_fp, (s.width, s.height, s.fps))); + // 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)) => { + 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(); + } + } + } + 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 +186,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 +252,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 }) } @@ -223,6 +266,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", @@ -349,4 +434,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 + )); + } } diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 50fb532..f786850 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -286,13 +286,19 @@ 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")?; + }, + // GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), not a QUIC + // close code — wiring it to skip-linger is a follow-up, so this path keeps normal keep-alive + // (a fresh, never-set flag). + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .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 @@ -397,6 +403,68 @@ 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) +} + +/// 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 @@ -414,8 +482,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; @@ -434,17 +508,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); @@ -518,7 +596,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 @@ -538,9 +616,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")?, @@ -549,12 +633,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. @@ -566,7 +652,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(); @@ -580,6 +665,22 @@ 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; + // 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(); // Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is @@ -645,6 +746,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; @@ -654,58 +756,71 @@ 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. + // 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 — 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(); // 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) } } } @@ -713,26 +828,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, @@ -740,7 +862,6 @@ fn stream_body( pkt_us = mx_pkt, send_us = mx_send, cap_us = mx_cap, - max_pkts = mx_pkts, "video: streaming (perf)" ); } else { @@ -753,7 +874,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( @@ -792,7 +913,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, @@ -805,13 +926,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(); @@ -889,4 +1008,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/main.rs b/crates/punktfunk-host/src/main.rs index bb859fd..725e26d 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -418,6 +418,20 @@ 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()), + // Disconnect-detection latency (QUIC control-connection idle timeout): --idle-timeout-ms + // overrides PUNKTFUNK_IDLE_TIMEOUT_MS; absent = the core default (8s). + idle_timeout: get("--idle-timeout-ms") + .and_then(|s| s.trim().parse::().ok()) + .filter(|&ms| ms > 0) + .map(std::time::Duration::from_millis) + .or_else(punktfunk1::idle_timeout_from_env), }) } // Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point. @@ -501,6 +515,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 +561,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 +603,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 +731,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 +746,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/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index fda1bef..59cca65 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -156,6 +156,11 @@ 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_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)) @@ -210,6 +215,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"), @@ -376,6 +382,10 @@ struct LocalSummary { pin_pending: bool, /// Native pairing knocks awaiting the operator's approval (count only). pending_approvals: u32, + /// Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned + /// (`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is + /// held; the tray surfaces it + a one-click release. Active (in-use) displays are not counted. + kept_displays: u32, } /// A paired (certificate-pinned) Moonlight client. @@ -954,6 +964,304 @@ 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. 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, +} + +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(), + "mode_conflict".into(), + "identity".into(), + "layout".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` (the gaming-rig preset) is +/// honored (the display is Pinned; free it via `POST /display/release`). +#[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 = "Malformed policy body", 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 { + // `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux + // registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch). + 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() +} + +/// 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, + /// 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. +#[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, + 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(), + }) +} + +/// 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 }) +} + +/// 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, @@ -1026,6 +1334,11 @@ async fn get_local_summary(State(st): State>) -> Json = body["enforced"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + // 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")); + } + + /// 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( diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index b0b593f..b320cf8 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -75,6 +75,41 @@ 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, + /// Control-connection idle timeout — the **disconnect-detection latency** (how long a vanished + /// client takes to be declared dead, which bounds how fast a dropped session tears down / lingers + /// and thus the reconnect-overlap window). `None` = the core default (8s). Set from + /// `PUNKTFUNK_IDLE_TIMEOUT_MS`; clamped to a ≥1s floor with a keep-alive that scales to it so a + /// live session never false-closes. + pub idle_timeout: 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 +178,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, @@ -153,6 +191,17 @@ pub(crate) struct NativeServe { /// overflow clients wait in the accept queue. Override with `--max-concurrent`. pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4; +/// The control-connection idle timeout (disconnect-detection latency) from +/// `PUNKTFUNK_IDLE_TIMEOUT_MS`; `None` (unset/invalid/zero) = the core default (8s). Clamped +/// downstream to a ≥1s floor with a keep-alive that scales to it, so a live session never false-closes. +pub(crate) fn idle_timeout_from_env() -> Option { + std::env::var("PUNKTFUNK_IDLE_TIMEOUT_MS") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .filter(|&ms| ms > 0) + .map(std::time::Duration::from_millis) +} + pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { Punktfunk1Options { port: cfg.port, @@ -165,6 +214,8 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { allow_pairing: false, pairing_pin: None, paired_store: None, + data_port: cfg.data_port, + idle_timeout: idle_timeout_from_env(), } } @@ -178,10 +229,11 @@ pub(crate) async fn serve( .context("load host identity (~/.config/punktfunk)")?; let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem) .map_err(|e| anyhow!("cert fingerprint: {e}"))?; - let ep = endpoint::server_with_identity( + let ep = endpoint::server_with_identity_idle( ([0, 0, 0, 0], opts.port).into(), &identity.cert_pem, &identity.key_pem, + opts.idle_timeout.unwrap_or(endpoint::DEFAULT_IDLE_TIMEOUT), ) .map_err(|e| anyhow!("QUIC server endpoint: {e}"))?; tracing::info!( @@ -341,6 +393,18 @@ 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; + +/// QUIC application error code a client closes with on a **deliberate quit** (a user "stop", not a +/// network drop). The host reads it off the connection's `ApplicationClosed` reason and tears the +/// session's virtual display down IMMEDIATELY, skipping the keep-alive linger — an unwanted disconnect +/// (idle timeout / reset / any other code) still lingers so a reconnect can resume. Shared with the +/// clients via `punktfunk_core::quic::QUIT_CLOSE_CODE`. +const QUIT_CODE: u32 = punktfunk_core::quic::QUIT_CLOSE_CODE; + /// 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. @@ -651,8 +715,9 @@ async fn serve_session( let source = opts.source; let frames = opts.frames; + let data_port = opts.data_port; 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 +749,74 @@ 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, preempt_same_identity, Admission}; + let peer_fp = endpoint::peer_fingerprint(&conn); + + // Same-client RECONNECT preempt (design §5.3 "preempts downstream"): if THIS client + // already has a live session, it's the zombie of an unwanted disconnect whose QUIC idle + // timer hasn't fired yet (detection lags a drop by up to `max_idle_timeout`). Signal it to + // stop and give it the release grace so it tears its display down — which, keep-alive on, + // lingers — and THIS reconnect REUSES that kept display below instead of landing on a + // fresh SECOND one. Independent of the mode_conflict arm (it's our OWN prior session, not + // a conflict with a different client), and it runs before we register ourselves so we + // never signal our own stop flag. + let own_zombies = preempt_same_identity(peer_fp); + if !own_zombies.is_empty() { + tracing::info!( + count = own_zombies.len(), + "reconnect: preempting this client's own zombie session(s) so the kept display is reused" + ); + for z in &own_zombies { + z.store(true, Ordering::SeqCst); + } + // Same blind release grace the steal path uses — lets the zombie's loops notice the + // stop flag and drop its display (→ Lingering) before we acquire below. + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + } + + match admit(peer_fp) { + 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}"); + // 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}"); + } + } + } + crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height) .context("client-requested mode")?; @@ -797,10 +930,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); @@ -860,9 +995,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:?}"))??; @@ -1046,15 +1181,41 @@ async fn serve_session( // Stop signal: stream duration elapsed or the client went away. let stop = Arc::new(AtomicBool::new(false)); + // Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the + // client closed the connection with `QUIT_CODE` — a user "stop", which skips the keep-alive linger. + // A bare disconnect / idle timeout leaves it false → the display lingers for a reconnect. + let quit = Arc::new(AtomicBool::new(false)); { let stop = stop.clone(); + let quit = quit.clone(); let conn = conn.clone(); tokio::spawn(async move { - conn.closed().await; + let reason = conn.closed().await; + if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac) + if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE)) + { + quit.store(true, Ordering::SeqCst); + } stop.store(true, Ordering::SeqCst); }); } + // 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 @@ -1153,6 +1314,7 @@ async fn serve_session( crate::encode::ChromaFormat::Yuv420 }; let stop_stream = stop.clone(); + let quit_stream = quit.clone(); let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream // Per-AU host-timing emission (0xCF): only when the client advertised the cap bit. All @@ -1168,29 +1330,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:?}"))?; @@ -1212,6 +1386,7 @@ async fn serve_session( mode, seconds, stop: stop_stream, + quit: quit_stream, reconfig: reconfig_rx, keyframe: keyframe_rx, compositor, @@ -2751,6 +2926,9 @@ struct SessionContext { seconds: u32, /// Session stop flag (set on disconnect / reconnect-preempt). stop: Arc, + /// Deliberate-quit flag (set when the client closed with `QUIT_CODE`): the display lease reads it + /// on teardown to skip the keep-alive linger for a user "stop" (vs. an unwanted disconnect). + quit: Arc, /// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode. reconfig: std::sync::mpsc::Receiver, /// Client decode-recovery keyframe requests. @@ -2810,6 +2988,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { mode, seconds, stop, + quit, reconfig, keyframe, compositor, @@ -2860,7 +3039,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) .then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); let (mut capturer, mut enc, mut frame, mut interval) = - build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan)?; + build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?; // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). #[cfg(target_os = "windows")] drop(_idd_setup_guard); @@ -3028,6 +3207,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { bitrate_kbps, bit_depth, plan, + &quit, )?; Ok((new_vd, pipe)) })(); @@ -3071,7 +3251,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // Build the new pipeline BEFORE dropping the old one: the host already acked // the switch as accepted, so a rebuild failure must not kill an otherwise // healthy session — keep streaming the current mode and log instead. - match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan) { + match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) { Ok(next_pipe) => { (capturer, enc, frame, interval) = next_pipe; cur_mode = new_mode; @@ -3192,6 +3372,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { bitrate_kbps, bit_depth, plan, + &quit, ) { Ok(p) => break p, Err(e2) => { @@ -3418,6 +3599,7 @@ fn build_pipeline_with_retry( bitrate_kbps: u32, bit_depth: u8, plan: crate::session_plan::SessionPlan, + quit: &Arc, ) -> Result { // ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take @@ -3444,7 +3626,7 @@ fn build_pipeline_with_retry( const MAX_ATTEMPTS: u32 = 8; let mut backoff = std::time::Duration::from_millis(500); for attempt in 1..=MAX_ATTEMPTS { - match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan) { + match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) { Ok(pipe) => { if attempt > 1 { tracing::info!(attempt, "pipeline up after retry"); @@ -3507,8 +3689,15 @@ fn build_pipeline( bitrate_kbps: u32, bit_depth: u8, plan: crate::session_plan::SessionPlan, + quit: &Arc, ) -> 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. The + // `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger. + let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone()) + .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 @@ -3581,6 +3770,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::*; @@ -3756,10 +3982,18 @@ mod tests { /// End-to-end through the C ABI — the exact contract platform clients (Swift) link: /// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) → /// `punktfunk_connection_next_au` pulls verified frames → `punktfunk_connection_send_input` + /// In-process-host tests each spin up a host on a fixed loopback port and share the process-global + /// admission table, so they must NOT run concurrently: a same-identity connection in one test would + /// fire the reconnect-preempt (`preempt_same_identity`) against another test's live session and + /// close it. Serialize them on this lock. Poison-tolerant (`into_inner`) so a failing test doesn't + /// cascade a poison error into the others. + static SESSION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// enqueues → `punktfunk_connection_close`. Three sequential sessions against ONE host /// process prove the persistent listener, and a wrong pin is rejected. #[test] fn c_abi_connection_roundtrip() { + let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); use punktfunk_core::abi::{ punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode, punktfunk_connection_send_input, @@ -3778,6 +4012,8 @@ mod tests { allow_pairing: false, pairing_pin: None, paired_store: None, + data_port: None, + idle_timeout: None, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); @@ -3946,6 +4182,7 @@ mod tests { /// admitted to a session with no PIN and no reconnect. #[test] fn delegated_approval_admits_after_knock() { + let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); use punktfunk_core::client::NativeClient; use punktfunk_core::quic::endpoint; @@ -3972,6 +4209,8 @@ mod tests { allow_pairing: false, pairing_pin: None, paired_store: None, // unused: the shared `np` IS the store handle + data_port: None, + idle_timeout: None, }, 0, // no mgmt API in this test → advertise no `mgmt` mDNS port np_host, @@ -4055,6 +4294,7 @@ mod tests { /// identity gets a session on a pairing-required host; an anonymous client does not. #[test] fn pairing_ceremony_and_gate() { + let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); use punktfunk_core::client::NativeClient; use punktfunk_core::quic::endpoint; @@ -4070,6 +4310,8 @@ mod tests { allow_pairing: false, pairing_pin: Some("4321".into()), paired_store: Some(test_paired_path()), + data_port: None, + idle_timeout: None, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 28f039b..728095b 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -64,6 +64,43 @@ 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) {} + /// 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). @@ -403,21 +440,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"). 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"); - } - ActiveKind::DesktopGnome - if std::env::var_os("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY").is_none() => - { - std::env::set_var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY", "1"); - } - _ => {} - } + // 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) {} @@ -723,14 +750,87 @@ 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; + +// 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; + +// 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 +/// 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, + } +} + +/// 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")] #[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; +// 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..bf318b3 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/admission.rs @@ -0,0 +1,278 @@ +//! 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 + )) + } + } +} + +/// 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); + #[cfg(windows)] + if matches!(conflict, ModeConflict::Separate) { + return ModeConflict::Reject; + } + 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()) +} + +/// Pure core of [`preempt_same_identity`]: the stop flags of live sessions owned by the SAME client +/// as `req_identity` (its own zombies). Testable over a slice (the public fn locks the global table). +fn same_identity_stops( + req_identity: Option<[u8; 32]>, + live: &[LiveSession], +) -> Vec> { + live.iter() + .filter(|s| same_client(s.identity, req_identity)) + .map(|s| Arc::clone(&s.stop)) + .collect() +} + +/// Preempt this reconnecting client's OWN still-live session(s). A client has at most one live +/// session, so a new connection from an already-registered identity is a **reconnect** — the old +/// session is a zombie whose QUIC idle timer hasn't fired yet (an unwanted disconnect is only +/// declared dead after `max_idle_timeout`, ~seconds later). Return its stop flag(s) so the caller +/// signals them and waits the release grace: the zombie tears its display down, which (keep-alive on) +/// lingers, and THIS reconnect **reuses** that kept display instead of landing on a fresh SECOND one +/// (the "thrown onto a second display while the old one keeps streaming" bug). Anonymous (`None`) +/// never matches — same limitation as `steal`/`reject`. Call this BEFORE [`admit`] and before this +/// session registers itself, so it only ever signals a *prior* session's flag, never its own. +pub fn preempt_same_identity(req_identity: Option<[u8; 32]>) -> Vec> { + same_identity_stops(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 same_identity_stops_targets_own_zombie_only() { + let live = [ + sess(Some(1), (2560, 1440, 60)), // this client's prior (zombie) session + sess(Some(2), (1920, 1080, 60)), // a different client + ]; + // Reconnecting as client 1 → its own zombie's stop is returned (to preempt), not client 2's. + assert_eq!(same_identity_stops(fp(1), &live).len(), 1); + // A client with no prior session (fp 3) has nothing of its own to preempt. + assert_eq!(same_identity_stops(fp(3), &live).len(), 0); + // Anonymous never matches — we can't prove it's the same client. + assert_eq!(same_identity_stops(None, &live).len(), 0); + } + + #[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)) + )); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/identity.rs b/crates/punktfunk-host/src/vdisplay/identity.rs new file mode 100644 index 0000000..83de969 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/identity.rs @@ -0,0 +1,246 @@ +//! 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 std::sync::{Mutex, OnceLock}; + +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); + } + } +} + +/// 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::*; + + 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/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/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/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index a234d6a..743a9b7 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -67,13 +67,42 @@ 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]>, + /// 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, + /// 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 { pub fn new() -> Result { - Ok(KwinDisplay) + Ok(KwinDisplay::default()) } } @@ -82,14 +111,61 @@ impl VirtualDisplay for KwinDisplay { "kwin" } + fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) { + self.client_fp = fingerprint; + } + + fn last_identity_slot(&self) -> Option { + 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; + }; + 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 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(); 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,36 +183,70 @@ 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 }; - // 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 disabled = match crate::vdisplay::effective_topology() { + Topology::Exclusive => apply_virtual_primary(&name), + Topology::Primary => { + apply_virtual_primary_only(&name); + Vec::new() // nothing disabled → nothing to restore + } + 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 /// 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") @@ -213,26 +323,17 @@ 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) -} +/// 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 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`]). +/// 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 ours = format!("Virtual-{VOUT_NAME}"); let out = match std::process::Command::new("kscreen-doctor") .arg("-j") .output() @@ -248,22 +349,49 @@ fn other_enabled_outputs() -> 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() -> 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) @@ -271,15 +399,20 @@ fn apply_virtual_primary() -> 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)); + // 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 @@ -292,29 +425,33 @@ 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(name: &str) { + let ours = format!("Virtual-{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. +/// 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); } } @@ -388,10 +525,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:#}"))); } @@ -431,6 +569,7 @@ pub fn is_available() -> bool { fn run( width: u32, height: u32, + name: &str, setup_tx: &Sender>, stop: &AtomicBool, ) -> Result<()> { @@ -453,7 +592,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) @@ -522,3 +661,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"]); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/linux/mutter.rs b/crates/punktfunk-host/src/vdisplay/linux/mutter.rs index 92714b7..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() @@ -118,9 +137,30 @@ 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. + use crate::vdisplay::policy::Topology; + let topo = crate::vdisplay::effective_topology(); + let topo_policy = matches!(topo, Topology::Primary | Topology::Exclusive); + // Group-aware (§6.1): only the FIRST display of the group establishes the topology. A later + // sibling extends into the already-exclusive desktop — re-applying the sole-monitor config would + // 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 { match display_config().await { Ok(dc) => match get_state(&dc).await { Ok(state) => Some((dc, state)), @@ -152,8 +192,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 +382,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 +444,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 +475,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 +504,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 +519,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 +} diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs new file mode 100644 index 0000000..18b9df5 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -0,0 +1,623 @@ +//! 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 + } +} + +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 { + 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 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. + 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/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs new file mode 100644 index 0000000..ed602be --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -0,0 +1,940 @@ +//! 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 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. +//! +//! **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 owner's generation stamp). + pub slot: u64, + /// Backend name (`"pf-vdisplay"`, `"kwin"`, `"mutter"`, …). + 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, + /// 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. +#[derive(Clone, Debug, Default)] +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* +/// 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. +/// `quit` is the session's deliberate-quit flag: when the session ends with it set (the client closed +/// with the quit application code — a user "stop", not a network drop), the display is torn down +/// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger. +pub fn acquire( + vd: &mut Box, + mode: super::Mode, + quit: std::sync::Arc, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::acquire(vd, mode, quit) + } + #[cfg(not(target_os = "linux"))] + { + // Windows leases in the manager (its own linger); the deliberate-quit skip is not wired + // through there yet, so the flag is accepted but unused off Linux. + let _ = quit; + 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 { + #[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, + 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, + group: 1, + display_index: 0, + position: (0, 0), + identity_slot: None, + topology: topology_str(), + }) + .into_iter() + .collect(); + Snapshot { displays } + } + #[cfg(target_os = "linux")] + { + Snapshot { + displays: linux::snapshot(), + } + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + 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.) + let _ = slot; + usize::from(super::manager::force_release()) + } + #[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::{AtomicBool, AtomicU64, Ordering}; + use std::sync::{Arc, Mutex, Once, OnceLock}; + use std::time::{Duration, Instant}; + + use anyhow::Result; + + use super::DisplayInfo; + use crate::vdisplay::lifecycle::{self, Release}; + use crate::vdisplay::policy::{self, Layout, Linger}; + use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput}; + + /// 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, + /// 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, + /// 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, + } + + 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. 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) { + 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, restores) + } + + /// 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, 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, + "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, + quit: Arc, + ) -> VirtualOutput { + VirtualOutput { + node_id, + remote_fd: None, + preferred_mode, + keepalive: Box::new(DisplayLease { gen, quit }), + } + } + + pub(super) fn acquire( + vd: &mut Box, + mode: Mode, + quit: Arc, + ) -> Result { + ensure_timer(); + let backend = vd.name(); + let r = reg(); + + // 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 + // 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 + }) { + // 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, quit); + tracing::info!( + backend, + node_id = e.node_id, + "virtual display reused (keep-alive reconnect)" + ); + return Ok(out); + } + } + + // 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 + // 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 + // 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; + // 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) + let entry = Entry { + life, + keepalive: real.keepalive, + node_id, + preferred_mode, + mode, + backend, + identity_slot, + topology_restore, + gen, + }; + + // 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(); + // 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| group_key(e.backend, e.gen) == new_group) + .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, quit)) + } + + /// 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, force_immediate: bool) { + let Some(r) = REG.get() else { return }; + // A deliberate quit (the client closed with the quit code — a user "stop") tears the display + // down NOW, overriding the keep-alive linger; a bare disconnect honors the policy. + let linger = if force_immediate { + Linger::Immediate + } else { + linger() + }; + 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 => { + 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) + } + Release::Pin => { + tracing::info!( + backend = es[idx].backend, + "virtual display: last session left — pinned (keep-alive forever)" + ); + (None, None) + } + // Linux entries are single-session (refs == 1), so Decref never occurs; harmless. + 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 { + if force_immediate { + tracing::info!( + backend = e.backend, + "virtual display torn down (deliberate quit — keep-alive skipped)" + ); + } else { + tracing::info!( + backend = e.backend, + "virtual display torn down (keep-alive off / released)" + ); + } + drop(e); // outside the lock — the keepalive Drop may block + } + } + + /// 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(); + + // 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() + }; + + 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`)") + } + + /// 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, + topology: &str, + ) -> Vec { + use crate::vdisplay::layout::{self, Member}; + + // Small stable group ids by sorted group key — deterministic; in practice a host runs one live + // desktop backend → group 1 (with each gamescope spawn its own group). + 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, 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)| &group_key(row.backend, row.gen) == key) + .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 { + let Some(r) = REG.get() else { return 0 }; + 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() { + 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, 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, + "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, + /// The session's deliberate-quit flag: set when the client closes with the quit application + /// code (a user "stop", not a network drop), so this lease's `Drop` tears the display down + /// immediately instead of lingering. `false` on a bare disconnect → normal keep-alive. + quit: Arc, + } + + impl Drop for DisplayLease { + fn drop(&mut self) { + release(self.gen, self.quit.load(Ordering::SeqCst)); + } + } + + #[cfg(test)] + mod tests { + 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 { + 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 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). + 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/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 953ffb2..e2b20e5 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 @@ -131,6 +131,12 @@ enum MgrState { Idle, Active { mon: Monitor, refs: u32 }, Lingering { mon: Monitor, until: Instant }, + /// `keep_alive = forever` (gaming-rig): the monitor is kept indefinitely after the last session + /// leaves — like `Lingering` but the linger timer never tears it down. A reconnect preempts + + /// recreates it (same as `Lingering`, since a reused IddCx swap-chain is dead); only the mgmt + /// `/display/release` (or host shutdown) frees it. The physical screens stay off (exclusive) for + /// the box's life — the §8 release-now escape hatch (`force_release`) is the way back. + Pinned { mon: Monitor }, } /// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the @@ -169,10 +175,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 +194,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::MonitorIdentityMap::load()), }) } @@ -387,22 +392,28 @@ impl VirtualDisplayManager { let mut state = self.state.lock().unwrap(); let dev = self.ensure_device()?; - // IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the - // prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black - // screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a - // fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. + // IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client + // RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it + // hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and + // create a fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. // - // ONLY Lingering, NOT Active: an Active monitor still has a lease held — that's the build-retry - // path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session, - // NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every - // retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at - // 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD). - if matches!(*state, MgrState::Lingering { .. }) { - if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle) - { + // ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the + // build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a + // concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn + // REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool + // and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD). + if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) { + let taken = match std::mem::replace(&mut *state, MgrState::Idle) { + MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon), + other => { + *state = other; + None + } + }; + if let Some(mon) = taken { tracing::info!( old_target = mon.target_id, - "IDD-push reconnect — preempting the lingering monitor, recreating a fresh one" + "IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one" ); // SAFETY: `teardown` requires `dev` to be a valid control handle; `dev` is the value // `ensure_device()` returned above (cached handles are never closed — a dead one is @@ -458,12 +469,14 @@ impl VirtualDisplayManager { return Ok(self.output_for(mon)); } - // Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}. + // Idle or kept: repurpose a kept monitor / create a fresh one → Active{refs:1}. (In practice a + // kept Lingering/Pinned monitor was already preempted → Idle above; this arm is the defensive + // reuse path if a race left one here — it must stay exhaustive over `Pinned` regardless.) let mon = match std::mem::replace(&mut *state, MgrState::Idle) { - MgrState::Lingering { mut mon, .. } => { + MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => { tracing::info!( backend = self.driver.name(), - "virtual monitor reused (reconnect within the linger window)" + "virtual monitor reused (reconnect to a kept monitor)" ); if mon.mode != mode { // SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live @@ -527,10 +540,14 @@ 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). - let preferred_id = client_fp - .map(|fp| self.identity_map.lock().unwrap().resolve(fp)) - .unwrap_or(0); + // auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy + // 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 @@ -630,17 +647,36 @@ 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 std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() { - // 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"); + // 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 => { + // 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 => { + 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 } @@ -725,7 +761,9 @@ impl VirtualDisplayManager { fn release(&self, gen: u64) { let mut state = self.state.lock().unwrap(); let stale = match &*state { - MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen, + MgrState::Active { mon, .. } + | MgrState::Lingering { mon, .. } + | MgrState::Pinned { mon } => mon.gen != gen, MgrState::Idle => true, }; if stale { @@ -736,6 +774,14 @@ impl VirtualDisplayManager { mon, refs: refs - 1, }, + // Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`, + // else linger for the policy window before the timer tears it down. + MgrState::Active { mon, .. } if keep_alive_forever() => { + tracing::info!( + "virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release" + ); + MgrState::Pinned { mon } + } MgrState::Active { mon, .. } => { let ms = linger_ms(); tracing::info!( @@ -890,10 +936,139 @@ 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`). +/// 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"` | `"pinned"`. + 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)) + } + // Pinned (keep_alive=forever): kept indefinitely, no expiry — the console shows "Pinned". + MgrState::Pinned { mon } => (mon, "pinned", 0u32, None), + }; + 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 kept (LINGERING **or** PINNED) monitor now (the `/display/release` endpoint) — + /// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8 + /// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. An Active monitor is refused + /// (stopping a live session is session management, not display management). Returns `true` if a kept + /// 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 { .. } | MgrState::Pinned { .. }) { + let mon = match std::mem::replace(&mut *st, MgrState::Idle) { + MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon), + other => { + *st = other; + None + } + }; + if let Some(mon) = mon { + // 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 kept 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. 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, + // `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so + // this arm is only reached defensively (e.g. a caller that resolves ms without the pin + // check) — fall back to the default rather than a huge linger. + Linger::Forever => 10_000, + }; + } std::env::var("PUNKTFUNK_MONITOR_LINGER_MS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(10_000) } + +/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the +/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of +/// lingering. Unconfigured hosts are never forever (default is a short linger). +fn keep_alive_forever() -> bool { + use crate::vdisplay::policy::{prefs, Linger}; + prefs() + .configured_effective() + .map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever)) + .unwrap_or(false) +} + +/// 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 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 + } +} diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index 7b4ae93..e5f5411 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, @@ -353,6 +355,48 @@ pub(crate) type SavedConfig = (Vec, Vec 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); + Some((paths, modes)) +} + +/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would +/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually took. +unsafe fn count_other_active(keep_target_id: u32) -> 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 / @@ -363,6 +407,61 @@ const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001; // 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, 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; + } + } + // 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 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) +} + +/// **Primary (topology=primary)** — make the virtual output the PRIMARY display while KEEPING every +/// other display ACTIVE (unlike [`isolate_displays_ccd`], which deactivates them). Windows treats the +/// display whose source sits at the desktop origin `(0,0)` as primary, so we move the virtual's source +/// to `(0,0)` and shift every other active source to its right — all paths stay active. Done as ONE +/// atomic CCD `SetDisplayConfig` (NOT GDI `CDS_SET_PRIMARY`, which storms +/// `DXGI_ERROR_MODE_CHANGE_IN_PROGRESS` when another display is live — see [`set_active_mode`]). +/// Returns the original config to restore on teardown. +pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { @@ -385,36 +484,45 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option Option format!("punktfunk host {} — streaming", s.version), + // Idle, but surface a kept (lingering/pinned) display: it — and, under an exclusive + // topology, your physical monitors — is being held. Release it from the console. + _ if s.kept_displays > 0 => format!( + "punktfunk host {} — idle · {} display{} kept", + s.version, + s.kept_displays, + if s.kept_displays == 1 { "" } else { "s" } + ), _ => format!("punktfunk host {} — idle", s.version), }, } @@ -432,6 +444,7 @@ mod tests { native_paired_clients: 2, pin_pending: false, pending_approvals: 0, + kept_displays: 0, } } diff --git a/design/display-management.md b/design/display-management.md new file mode 100644 index 0000000..6e2a5e0 --- /dev/null +++ b/design/display-management.md @@ -0,0 +1,905 @@ +# Virtual-display management & lifecycle policy — design + +> **Status (2026-07-05):** **Stages 0–5 (§6A) DONE + on-glass validated; keep-alive reconnect +> hardened** (branch `display-mgmt-stage0`, not yet merged). Stage 5 §6A: 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`, the +> `PUT /display/layout` endpoint with group/position/index in `/display/state`, and the **web console +> arrangement table** — **live-validated on KWin `.116` + Mutter `.21`** (group model, positions, +> identity keying, group-aware exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors). The +> Stage-3 **KDE scaling round-trip is now proven live** (set 150 %/125 % → disconnect → reconnect → +> reapplied, seen in `kwinoutputconfig.json`). **Keep-alive reconnect hardening (`b53710d`, on-glass +> validated with the probe):** a same-client reconnect **preempts its own zombie** +> (`admission::preempt_same_identity` — fixes "reconnect within the idle-detection window lands on a +> fresh SECOND display while the old one keeps streaming"), a **deliberate quit skips the linger** +> (client closes with `QUIT_CLOSE_CODE` 0x51 → `registry::release(force_immediate)`; §5.1), and the QUIC +> control-connection idle timeout (the disconnect-detection latency) is **host-tunable** +> (`PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`, default 8 s). **Remaining Stage 5 = hardware-gated +> residuals only**: the per-group physical-restore EFFECT (needs a monitor-attached Linux box — the +> headless validation boxes report `also_disabled=[]`, so nothing is disabled to restore), wlroots +> `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert. See the **Status — +> handoff** block under §11 for the per-stage state and 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 +> 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 `Pinned` state — **shipped** (`ccbd7e8`, + `MgrState::Pinned`; compile-verified on `.173`, on-glass Windows Pinned pending). Freed via + `POST /display/release` (`force_release` handles Pinned) — the §8 escape hatch. `gaming-rig` (the + `forever` preset) is no longer mgmt-rejected and is enabled in the console; **on-glass validated on + Linux** (`.116` KWin: normal disconnect → `pinned`, no expiry; Release frees it). + +**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** (a user "stop", not a network drop) bypasses keep-alive: tear down + now. **Implemented on punktfunk/1** (`b53710d`, on-glass validated): the client closes the QUIC + connection with `QUIT_CLOSE_CODE` (0x51, shared in `core::quic`); the host reads the + `ApplicationClosed` reason and does `registry::release(force_immediate)` → `Linger::Immediate` → + teardown, skipping the linger. `NativeClient::disconnect_quit()` + `punktfunk-probe --quit` drive + it; GameStream `cancel`/quit-app (`h_cancel`) + the five real clients sending the code are + follow-ups. A plain disconnect / connection loss honors the policy (lingers for reconnect). +- A **same-client reconnect resumes** (never a fresh second display). A reconnect while the client's + own prior session is still `Active` — its QUIC idle timer hasn't fired, and detection lags a drop by + `max_idle_timeout` (default 8 s, host-tunable via `PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`) + — is recognised by `admission::preempt_same_identity` (same cert fingerprint): the host signals the + zombie's stop + waits the release grace, so it lingers and the reconnect **reuses** the kept display. + Without this, a reconnect inside the detection window landed on a fresh second display while the old + session kept streaming. **Implemented + on-glass validated** (`b53710d`); implements the "preempts + downstream" the admission layer already promised (§5.3). +- 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 incl. `Pinned` (forever) | +| 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. + +### 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 (§6A): DONE + on-glass validated (KWin `.116` + Mutter `.21`).** All §6A group semantics + landed + unit-tested, then live-validated (group model, positions, identity keying, group-aware + exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors; the dev VM itself is GPU-less): + **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). The **web arrangement table** + (`DisplayCard.tsx` `DisplayArrangement`, en+de) is also done, moved to its own **Virtual displays** nav + section with a full one-click-preset config surface. **Remaining = hardware-gated residuals only:** the + per-group physical-restore EFFECT (needs a monitor-attached box — the headless boxes report + `also_disabled=[]`), wlroots `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert — + see the Stage 5 entry below. Plus the **keep-alive reconnect hardening** (`b53710d`, on-glass validated): + same-client zombie preempt + deliberate-quit skip-linger + tunable idle timeout (§5.1) — what made + "reconnect resumes" actually hold under a fast reconnect. + +**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). + +**Validated since (2026-07-05):** the KWin **set-scaling ROUND-TRIP** — a client set 150 % then 125 % +in the streamed KDE session, disconnected, reconnected, and the scale was reapplied to the freshly +re-created `Virtual-punktfunk-` (proven in `kwinoutputconfig.json`); this closes the Stage-3 gate. +Also the §6A group model + group-aware exclusive/extend + 2 concurrent Mutter `RecordVirtual` monitors, +and the keep-alive reconnect hardening. + +**Still 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`**; GameStream +503 on-glass; and the **per-group physical-restore EFFECT** — a monitor-attached box is required to see +`exclusive` disable a physical output and the group restore re-enable it only after the last member drops +(the headless boxes report `also_disabled=[]`, so the group semantics are proven but the physical +toggle isn't). + +- **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. [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 + 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 — 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. [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. + *Validated on KDE (`.116`, 2026-07-05):* a client set 150 % then 125 % in the streamed session, + disconnected, reconnected (keep-alive off → full teardown+recreate), and the scale was reapplied to + the fresh `Virtual-punktfunk-` — confirmed in `kwinoutputconfig.json` (`scale=1.25` persisted by + connector name). This is the round-trip the persist mechanism was designed for. *(client-B-unaffected + under two concurrent sessions is folded into the Stage-5 two-session case.)* +- **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. [DONE ✓ — on-glass validated (KWin `.116` + Mutter `.21`); hardware-gated residuals deferred]** 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. + - **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).* + - **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.) + - **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 — hardware-gated residuals only (no more host/web build work):** + - **On-glass validation — mostly DONE (2026-07-05, KWin `.116` + Mutter `.21`):** the group model, + per-member positions, identity keying, group-aware `exclusive`/`extend` coexistence (a 2nd session + does NOT clobber the 1st's output), and **2 concurrent Mutter `RecordVirtual` monitors** are all + confirmed live; the keep-alive reconnect path (reuse, quit-skip-linger, tunable idle) was validated + deterministically with the probe. **STILL PENDING: the per-group physical-restore EFFECT** — both + boxes are headless so `also_disabled=[]` (nothing to disable → nothing to restore); seeing + `exclusive` black out a real monitor and the group restore re-enable it only after the LAST member + drops needs a **monitor-attached Linux box**. The group-restore LOGIC (`hand_off_restore`) is + unit-tested; only the physical effect is unobserved. + - **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). + - **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 + 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/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/docs-site/content/docs/virtual-displays.md b/docs-site/content/docs/virtual-displays.md new file mode 100644 index 0000000..a56e277 --- /dev/null +++ b/docs-site/content/docs/virtual-displays.md @@ -0,0 +1,151 @@ +--- +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:** **keep-alive** (linger, or **forever**), **topology** (extend / primary / +> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows *and* +> KDE/KWin), and **multi-monitor layout** (several clients as monitors of one desktop) are all +> enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a +> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway +> `exclusive`, and multi-display for a *single* client (that last is the next stage). + +## 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 (keep-alive **forever**), and whoever connects takes the box over. Release it from the console when you're done. | +| **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 (Host → *Virtual + displays* → *Release*). This is the gaming-rig model. + +Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down +immediately — a short linger makes reconnects smoother on both. + +**A reconnect always resumes the kept display** — the host recognises your device and hands back the +same display, even if you reconnect a second or two after dropping (before it has noticed you left). +**Deliberately quitting** (closing the client, not a network drop) tears the display down at once, +skipping the linger, so you don't leave a ghost behind. How quickly a *dropped* client is noticed is +the QUIC idle timeout — 8 s by default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see +[Legacy environment knobs](#legacy-environment-knobs)) if you want kept displays freed sooner. + +> **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 | ✅ | +| Exclusive | ✅ | ✅ | ⏳ following release | ✅ | + +### Conflict handling · identity · layout + +- **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**). On Linux, `separate` gives each client its own display on the shared desktop. On + **Windows** a second client is **rejected** (a clean "host busy") even under `separate` — two + clients can't yet share one virtual display's capture there (that's a later stage), so the live + session is protected instead. A same-client *reconnect* never conflicts — it resumes. +- **Identity** — whether each client gets a **stable display identity** so your desktop environment + remembers its settings (see [Persistent scaling](#persistent-scaling)): one shared identity, one + **per client**, or one **per client + resolution**. +- **Layout / max displays** — when several clients each become a monitor of one desktop, this places + them side by side (**auto**) or exactly where you arrange them in the console (**manual**, keyed to + each client), up to **max displays**. Arrange them under Host → *Virtual displays* once two or more + are streaming. + +## 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** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). | +| **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`) | + +One knob has no console equivalent — it's a transport tuning, not display policy: + +- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long the host waits before declaring a + *dropped* client gone, which is when a kept display starts its linger (or is freed). Lower it (e.g. + `3000`) to reclaim kept displays sooner after an ungraceful drop; it's clamped to ≥1 s and its + keep-alive ping scales with it, so a live session never false-disconnects. A deliberate quit is + instant regardless. Also `--idle-timeout-ms` on `punktfunk1-host`. + +## 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/include/punktfunk_core.h b/include/punktfunk_core.h index 4cbfc90..8773bb6 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -274,6 +274,15 @@ #define VIDEO_CAP_HOST_TIMING 8 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// QUIC application error code a punktfunk/1 client closes the control connection with on a +// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's +// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the +// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a +// reconnect can resume. Shared so host + every client agree on the code. +#define QUIT_CLOSE_CODE 81 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST diff --git a/packaging/arch/README.md b/packaging/arch/README.md index eadb6b5..bc86ced 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -176,8 +176,16 @@ Prefer explicit rules (or a firewall the shipped profiles don't cover)? Open the The **native `punktfunk/1`** plane: - **QUIC control plane: UDP 9777** (`serve --native-port N` to change). -- **Data plane: an *ephemeral* UDP port** the client hole-punches — nothing to open inbound as long - as outbound UDP is allowed (the host streams back out through the client-opened path). +- **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: @@ -200,7 +208,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,47999,48000/udp # GameStream video/control/audio sudo ufw allow 5353/udp # mDNS discovery -# The punktfunk/1 data plane is an ephemeral UDP port the host hole-punches — nothing to open here. +# 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): @@ -209,8 +219,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-48000, 5353 } accept # GameStream video/control/audio + mDNS -# The punktfunk/1 data plane is an ephemeral UDP port the host hole-punches — a stateful chain that -# accepts ct state established,related (as this one should) passes the return with nothing extra. +# 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 fe48e1b..3e1383a 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -374,9 +374,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 b69b284..74be8e3 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -76,8 +76,16 @@ open its port with the matching one-liner — `sudo ufw allow punktfunk-web` or Prefer explicit rules? Open the ports directly. The **native `punktfunk/1`** plane: - **QUIC control plane: UDP 9777** (`serve --native-port N` to change). -- **Data plane: an *ephemeral* UDP port** the client hole-punches — nothing to open inbound as long - as outbound UDP is allowed (the host streams back out through the client-opened path). +- **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: @@ -100,7 +108,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,47999,48000/udp # GameStream video/control/audio sudo ufw allow 5353/udp # mDNS discovery -# The punktfunk/1 data plane is an ephemeral UDP port the host hole-punches — nothing to open here. +# 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): @@ -109,7 +119,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 diff --git a/scripts/host.env.example b/scripts/host.env.example index 63ba9c1..9d26691 100644 --- a/scripts/host.env.example +++ b/scripts/host.env.example @@ -48,6 +48,15 @@ PUNKTFUNK_ZEROCOPY=1 #PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput #PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent #PUNKTFUNK_PERF=1 # per-stage timing logs +# Display-management policy (keep-alive · topology · conflict · identity · layout · max) is set in the +# web console (Host → Virtual displays) → ~/.config/punktfunk/display-settings.json, NOT here; a +# settings file supersedes the legacy PUNKTFUNK_MONITOR_LINGER_MS / _NO_ISOLATE / *_VIRTUAL_PRIMARY +# knobs. One transport knob has no console equivalent: +#PUNKTFUNK_IDLE_TIMEOUT_MS=8000 # disconnect-detection latency: how long before a DROPPED client is + # declared gone (a kept display then starts its linger, or frees). + # Lower (e.g. 3000) to reclaim kept displays sooner after an + # ungraceful drop; clamped ≥1s, keep-alive ping scales with it so a + # live session never false-disconnects. A deliberate quit is instant. # Full-chroma 4:4:4 (HEVC Range Extensions) — sharper text/desktop, no chroma loss. Honored only on # the punktfunk/1 native path when the client advertises 4:4:4 AND the GPU supports it (probed; else # the session stays 4:2:0). HEVC-only; independent of 10-bit. NVENC (NVIDIA) is the validated path; diff --git a/web/messages/de.json b/web/messages/de.json index f9e2087..35e4e54 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -4,6 +4,7 @@ "app_tagline": "Verwaltungskonsole", "nav_dashboard": "Übersicht", "nav_host": "Host", + "nav_displays": "Virtuelle Anzeigen", "nav_clients": "Gekoppelte Geräte", "nav_pairing": "Kopplung", "nav_library": "Bibliothek", @@ -47,6 +48,61 @@ "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_config_title": "Konfiguration", + "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_keep": "Behalten für", + "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": "Ä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", + "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", + "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": "„Aus“ baut die Anzeige sofort beim Trennen ab. Halte sie (und bei gamescope ihr Spiel) am Leben, damit ein schnelles Wiederverbinden sofort fortsetzt, statt neu aufzubauen.", + "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": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.", + "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 bc00fe1..a88e977 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -4,6 +4,7 @@ "app_tagline": "management console", "nav_dashboard": "Dashboard", "nav_host": "Host", + "nav_displays": "Virtual displays", "nav_clients": "Paired clients", "nav_pairing": "Pairing", "nav_library": "Library", @@ -47,6 +48,61 @@ "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_config_title": "Configuration", + "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_keep": "Keep for", + "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": "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", + "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", + "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": "Off tears the display down as soon as the client disconnects. Keep it alive (and, on gamescope, its game) so a quick reconnect resumes instantly instead of rebuilding.", + "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": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.", + "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/components/app-shell.tsx b/web/src/components/app-shell.tsx index 546733b..4499f5f 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -4,6 +4,7 @@ import { GaugeCircle, KeyRound, LibraryBig, + MonitorPlay, ScrollText, Server, Settings, @@ -21,6 +22,7 @@ const MLink = motion(Link); const NAV = [ { to: "/", icon: Activity, label: () => m.nav_dashboard() }, { to: "/host", icon: Server, label: () => m.nav_host() }, + { to: "/displays", icon: MonitorPlay, label: () => m.nav_displays() }, { to: "/library", icon: LibraryBig, label: () => m.nav_library() }, { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, diff --git a/web/src/routes/displays.tsx b/web/src/routes/displays.tsx new file mode 100644 index 0000000..959d331 --- /dev/null +++ b/web/src/routes/displays.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SectionDisplays } from "@/sections/Displays"; + +export const Route = createFileRoute("/displays")({ component: SectionDisplays }); diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx new file mode 100644 index 0000000..e6862ba --- /dev/null +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -0,0 +1,629 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "@unom/ui/button"; +import { type FC, type ReactNode, useEffect, useState } from "react"; +import { + getGetDisplayStateQueryKey, + getGetDisplaySettingsQueryKey, + useGetDisplaySettings, + useGetDisplayState, + useReleaseDisplay, + useSetDisplayLayout, + useSetDisplaySettings, +} from "@/api/gen/display/display"; +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"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { m } from "@/paraglide/messages"; + +/** + * 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 every successful apply. + const [draft, setDraft] = useState(null); + useEffect(() => { + if (q.data && draft === null) setDraft(q.data.settings); + }, [q.data, draft]); + + // 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: policy }, + { + onSuccess: (res) => { + setDraft(res.settings); + qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); + }, + }, + ); + + return ( +
+ + + {m.display_config_title()} + + +

{m.host_displays_help()}

+ + {q.data && draft && ( + + )} + +
+
+ + + {m.display_live()} + + + + + +
+ ); +}; + +/** 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; + // The duration value, remembered across the Off/Keep toggle so switching back restores it. + const [keepSecs, setKeepSecs] = useState(ka.mode === "duration" ? ka.seconds : 300); + + return ( +
+ {/* One-click presets — a 2-up grid so each has room to breathe */} +
+ +
+ {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 disabled = busy || soon; + const pick = () => { + if (!disabled) pickPreset(id); + }; + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + pick(); + } + }} + className={cn( + "flex h-full flex-col p-4", + disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer", + selected && "ring-2 ring-primary", + )} + > +
+ + {(PRESET_LABEL[id] ?? (() => id))()} + {soon && ( + + {m.display_preset_soon()} + + )} + + {selected && ( + {m.display_preset_current()} + )} +
+ {summary && ( +

{summary}

+ )} + {fields && ( +
+ {fmtKeepAlive(fields.keep_alive)} + {tr(TOPOLOGY_LABEL, fields.topology)} + {tr(CONFLICT_LABEL, fields.mode_conflict)} + {tr(IDENTITY_LABEL, fields.identity)} +
+ )} +
+ ); + })} +
+
+ + {/* Custom: every option by hand */} + {isCustom && ( +
+ +
+ + + {ka.mode === "duration" && ( +
+ { + const n = Math.max(0, Number(e.target.value) || 0); + setKeepSecs(n); + setDraft({ ...draft, keep_alive: { mode: "duration", seconds: n } }); + }} + /> + + {m.display_keep_alive_seconds()} + +
+ )} +
+
+ + 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 config field — label, then the control, then optional help. The single source of the + * label→control→help spacing so every field (keep-alive, the button groups, max-displays) lines up. */ +const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({ + label, + help, + children, +}) => ( +
+ + {children} + {help &&

{help}

} +
+); + +/** A [`Field`] whose control is a row of mutually-exclusive option buttons (topology / conflict / …). */ +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) => ( + + ))} +
+
+); + +/** + * 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 ( +
+ {kept.length > 0 && ( +
+ +
+ )} + {displays.length === 0 ? ( +

{m.display_none_live()}

+ ) : ( +
    + {displays.map((d) => ( + doRelease(d.slot)} + /> + ))} +
+ )} + +
+ ); +}; + +/** + * 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)} +

+ )} + +
+ ); +}; + +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) { + const data = err.data as { error?: string } | undefined; + return data?.error ?? err.message; + } + return err ? String(err) : undefined; +}; + +/** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty + * now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and + * freed via Release. */ +const DISABLED_PRESETS: ReadonlySet = new Set(); + +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 CONFLICT_LABEL: Record string> = { + separate: m.display_conflict_separate, + steal: m.display_conflict_steal, + join: m.display_conflict_join, + reject: m.display_conflict_reject, +}; + +const IDENTITY_LABEL: Record string> = { + shared: m.display_identity_shared, + "per-client": m.display_identity_per_client, + "per-client-mode": m.display_identity_per_client_mode, +}; + +const LAYOUT_LABEL: Record string> = { + "auto-row": m.display_layout_auto_row, + manual: m.display_layout_manual, +}; + +/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */ +const tr = (map: Record string>, key: string | null | undefined): string => { + const fn = key == null ? undefined : map[key]; + return fn ? fn() : String(key ?? ""); +}; + +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 "∞"; + } +}; diff --git a/web/src/sections/Displays/index.tsx b/web/src/sections/Displays/index.tsx new file mode 100644 index 0000000..b707a95 --- /dev/null +++ b/web/src/sections/Displays/index.tsx @@ -0,0 +1,22 @@ +import Section from "@unom/ui/section"; +import type { FC } from "react"; +import { useLocale } from "@/lib/i18n"; +import { m } from "@/paraglide/messages"; +import { DisplaySection } from "./DisplayCard"; + +/** + * The **Virtual displays** page (design/display-management.md): the host's virtual-display policy + * (presets + every axis) plus the live-display list + multi-monitor arrangement. Its own nav + * section — the config surface is large enough to warrant the room, and it kept the Host page busy. + */ +export const SectionDisplays: FC = () => { + useLocale(); + return ( +
    +
    +

    {m.nav_displays()}

    + +
    +
    + ); +}; diff --git a/web/src/sections/Host/index.tsx b/web/src/sections/Host/index.tsx index 81c0d27..322518d 100644 --- a/web/src/sections/Host/index.tsx +++ b/web/src/sections/Host/index.tsx @@ -9,7 +9,5 @@ export const SectionHost: FC = () => { const host = useGetHostInfo(); const compositors = useListCompositors(); - return ( - } /> - ); + return } />; };