merge: display-management (Stages 0-5 §6A + keep-alive hardening + gaming-rig)
apple / swift (push) Successful in 1m15s
windows-host / package (push) Failing after 3m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m2s
release / apple (push) Successful in 8m43s
android / android (push) Successful in 4m17s
ci / rust (push) Failing after 29s
ci / web (push) Successful in 49s
arch / build-publish (push) Successful in 5m33s
ci / docs-site (push) Successful in 59s
apple / screenshots (push) Successful in 5m42s
deb / build-publish (push) Successful in 3m7s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 28s
ci / bench (push) Successful in 4m41s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
flatpak / build-publish (push) Successful in 4m30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m21s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m25s

Merges display-mgmt-stage0 — the user-configurable virtual-display policy layer above the
per-compositor backends. On-glass validated (KWin .116 + Mutter .21; Windows compile-verified .173):

- Policy surface (keep_alive · topology · conflict · identity · layout · max) →
  display-settings.json, console-editable via /api/v1/display/{settings,state,release,layout} + a
  dedicated "Virtual displays" console section. All five axes enforced, not just stored.
- Lifecycle: pure state machine + Linux keep-alive pool (registry + DisplayLease ownership split),
  incl. keep_alive=forever/Pinned (freed via /display/release); topology extend/primary/exclusive
  (group-aware); per-client identity (KWin per-slot names → KDE scaling round-trips); mode_conflict
  admission (Windows default reject, single-capturer IDD); §6A multi-monitor (display groups +
  layout engine + console arrangement table — several clients as monitors of one desktop).
- Keep-alive reconnect hardened: same-client zombie preempt (never a 2nd display), deliberate-quit
  skip-linger (QUIT_CLOSE_CODE), tunable idle timeout (PUNKTFUNK_IDLE_TIMEOUT_MS).

Conflicts (packaging/{arch,debian}/README.md firewall docs): kept main's ufw/nft port commands +
the branch's --data-port documentation. build + clippy -D warnings + cargo test --workspace
(18 suites, 0 failed) green on the merged tree.
This commit is contained in:
2026-07-05 18:22:17 +00:00
43 changed files with 7161 additions and 483 deletions
+5
View File
@@ -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 - **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 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. 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; - **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 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 monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
+658 -2
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.6.0" "version": "0.7.4"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/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": { "/api/v1/gpus": {
"get": { "get": {
"tags": [ "tags": [
@@ -1601,6 +1819,99 @@
"av1" "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": { "ApiError": {
"type": "object", "type": "object",
"description": "Error envelope for every non-2xx response.", "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": "`{\"<identity_slot>\": {\"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": { "GameEntry": {
"type": "object", "type": "object",
"description": "One title in the unified library, regardless of which store it came from.", "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": { "LaunchSpec": {
"type": "object", "type": "object",
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.", "description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
@@ -2118,6 +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": { "LocalSummary": {
"type": "object", "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.", "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", "paired_clients",
"native_paired_clients", "native_paired_clients",
"pin_pending", "pin_pending",
"pending_approvals" "pending_approvals",
"kept_displays"
], ],
"properties": { "properties": {
"audio_streaming": { "audio_streaming": {
"type": "boolean", "type": "boolean",
"description": "True while the audio stream thread is running." "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": { "native_paired_clients": {
"type": "integer", "type": "integer",
"format": "int32", "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": { "NativeClient": {
"type": "object", "type": "object",
"description": "A paired native (punktfunk/1) client.", "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": { "RuntimeStatus": {
"type": "object", "type": "object",
"description": "Live host status (changes as clients launch/end sessions).", "description": "Live host status (changes as clients launch/end sessions).",
@@ -2740,6 +3382,16 @@
"example": "1234" "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": { "securitySchemes": {
@@ -2763,6 +3415,10 @@
"name": "gpu", "name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use" "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", "name": "clients",
"description": "Paired Moonlight client management" "description": "Paired Moonlight client management"
+26 -2
View File
@@ -73,6 +73,14 @@ struct Args {
/// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs /// `--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. /// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back.
rich_input_test: bool, 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<u64>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream. /// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
remode: Option<(Mode, u32)>, remode: Option<(Mode, u32)>,
@@ -211,6 +219,8 @@ fn parse_args() -> Args {
mic_burst: argv.iter().any(|a| a == "--mic-burst"), mic_burst: argv.iter().any(|a| a == "--mic-burst"),
touch_test: argv.iter().any(|a| a == "--touch-test"), touch_test: argv.iter().any(|a| a == "--touch-test"),
rich_input_test: argv.iter().any(|a| a == "--rich-input-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, pin,
remode, remode,
pair: get("--pair").map(String::from), pair: get("--pair").map(String::from),
@@ -1041,6 +1051,9 @@ async fn session(args: Args) -> Result<()> {
let mut net_us_v: Vec<u64> = Vec::new(); let mut net_us_v: Vec<u64> = Vec::new();
let mut last_rx = std::time::Instant::now(); let mut last_rx = std::time::Instant::now();
let started = 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. // 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_loss_report = std::time::Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64); let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
@@ -1076,7 +1089,7 @@ async fn session(args: Args) -> Result<()> {
{ {
break; 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) || last_rx.elapsed() > std::time::Duration::from_secs(8)
{ {
break; 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 result
} }
+28 -1
View File
@@ -179,6 +179,10 @@ pub struct NativeClient {
/// Speed-test accumulator, shared with the data-plane pump + control task. /// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
/// 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<AtomicBool>,
/// Cumulative count of access units the reassembler gave up on (FEC couldn't recover), mirrored /// 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 /// 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 /// 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::<CtrlRequest>(); let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false)); 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 mode_slot = Arc::new(std::sync::Mutex::new(mode));
let probe = Arc::new(Mutex::new(ProbeState::default())); let probe = Arc::new(Mutex::new(ProbeState::default()));
let frames_dropped = Arc::new(AtomicU64::new(0)); let frames_dropped = Arc::new(AtomicU64::new(0));
@@ -338,6 +343,7 @@ impl NativeClient {
let host = host.to_string(); let host = host.to_string();
let shutdown_w = shutdown.clone(); let shutdown_w = shutdown.clone();
let quit_w = quit.clone();
let mode_slot_w = mode_slot.clone(); let mode_slot_w = mode_slot.clone();
let probe_w = probe.clone(); let probe_w = probe.clone();
let frames_dropped_w = frames_dropped.clone(); let frames_dropped_w = frames_dropped.clone();
@@ -388,6 +394,7 @@ impl NativeClient {
ctrl_tx: ctrl_tx_pump, ctrl_tx: ctrl_tx_pump,
ready_tx, ready_tx,
shutdown: shutdown_w, shutdown: shutdown_w,
quit: quit_w,
mode_slot: mode_slot_w, mode_slot: mode_slot_w,
probe: probe_w, probe: probe_w,
frames_dropped: frames_dropped_w, frames_dropped: frames_dropped_w,
@@ -430,6 +437,7 @@ impl NativeClient {
ctrl_tx, ctrl_tx,
probe, probe,
shutdown, shutdown,
quit,
worker: Some(worker), worker: Some(worker),
frames_dropped, frames_dropped,
hot_tids, hot_tids,
@@ -764,6 +772,15 @@ impl NativeClient {
.send(rich) .send(rich)
.map_err(|_| PunktfunkError::Closed) .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 { impl Drop for NativeClient {
@@ -802,6 +819,8 @@ struct WorkerArgs {
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>, ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>, ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
quit: Arc<AtomicBool>,
mode_slot: Arc<std::sync::Mutex<Mode>>, mode_slot: Arc<std::sync::Mutex<Mode>>,
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
frames_dropped: Arc<AtomicU64>, frames_dropped: Arc<AtomicU64>,
@@ -838,6 +857,7 @@ async fn worker_main(args: WorkerArgs) {
ctrl_tx, ctrl_tx,
ready_tx, ready_tx,
shutdown, shutdown,
quit,
mode_slot, mode_slot,
probe, probe,
frames_dropped, frames_dropped,
@@ -1210,5 +1230,12 @@ async fn worker_main(args: WorkerArgs) {
}) })
.await; .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");
} }
+45 -13
View File
@@ -122,6 +122,13 @@ pub const VIDEO_CAP_444: u8 = 0x04;
/// stage. Purely observability — never changes what the host encodes. /// stage. Purely observability — never changes what the host encodes.
pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08; 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** /// [`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 /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this. /// advertise this.
@@ -1743,20 +1750,31 @@ pub mod endpoint {
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so /// 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 /// 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`. /// 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<quinn::TransportConfig> { fn stream_transport() -> Arc<quinn::TransportConfig> {
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<quinn::TransportConfig> {
use std::time::Duration; use std::time::Duration;
// 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its let idle = idle.max(Duration::from_secs(1));
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates let keep_alive = (idle / 2).min(Duration::from_secs(4));
// 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 mut t = quinn::TransportConfig::default(); let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some( 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) Arc::new(t)
} }
@@ -1767,23 +1785,36 @@ pub mod endpoint {
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?; .map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert); let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); 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) — /// 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 /// 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( pub fn server_with_identity(
addr: std::net::SocketAddr, addr: std::net::SocketAddr,
cert_pem: &str, cert_pem: &str,
key_pem: &str, key_pem: &str,
) -> anyhow_result::Result<quinn::Endpoint> {
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<quinn::Endpoint> { ) -> anyhow_result::Result<quinn::Endpoint> {
use rustls::pki_types::pem::PemObject; use rustls::pki_types::pem::PemObject;
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes()) 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}")))?; .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()) 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}")))?; .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 /// 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>, cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>, key_der: rustls::pki_types::PrivateKeyDer<'static>,
addr: std::net::SocketAddr, addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> { ) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed // 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) let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?; .map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg)); 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)?) Ok(quinn::Endpoint::server(server_config, addr)?)
} }
+18 -2
View File
@@ -416,7 +416,14 @@ impl UdpTransport {
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the /// 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. /// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> { pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
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<Self> {
socket.connect(peer)?; socket.connect(peer)?;
super::qos::grow_socket_buffers(&socket); super::qos::grow_socket_buffers(&socket);
// The native data plane is video-dominant — tag it as the video class (opt-in via // 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, fallback_peer: &str,
punch_timeout: std::time::Duration, punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> { ) -> 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))?; socket.set_read_timeout(Some(punch_timeout))?;
let deadline = std::time::Instant::now() + punch_timeout; let deadline = std::time::Instant::now() + punch_timeout;
let mut buf = [0u8; 64]; let mut buf = [0u8; 64];
+34 -3
View File
@@ -170,18 +170,26 @@ pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver /// 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 /// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
/// true, Moonlight offers its per-app HDR toggle. /// 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 `<App>` start tag. A
/// pretty-print newline between `<root>` and the first `<App>` 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 { pub fn applist_xml() -> String {
let hdr = u8::from(crate::gamestream::host_hdr_capable()); let hdr = u8::from(crate::gamestream::host_hdr_capable());
let mut xml = let mut xml =
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n"); String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?><root status_code=\"200\">");
for app in catalog() { for app in catalog() {
xml.push_str(&format!( xml.push_str(&format!(
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n", "<App><IsHdrSupported>{hdr}</IsHdrSupported><AppTitle>{}</AppTitle><ID>{}</ID></App>",
xml_escape(&app.title), xml_escape(&app.title),
app.id app.id
)); ));
} }
xml.push_str("</root>\n"); xml.push_str("</root>");
xml xml
} }
@@ -249,4 +257,27 @@ mod tests {
assert!(xml.starts_with("<?xml")); assert!(xml.starts_with("<?xml"));
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count()); assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
} }
/// Regression: the applist MUST be whitespace-free between elements. Moonlight-Android's
/// `getAppListByReader` calls `appList.getLast()` on every text node before an `<App>` has been
/// pushed, so a pretty-print newline between `<root>` and the first `<App>` 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();
// <root> is immediately followed by the first <App> — no whitespace text node while the
// parser's app list is still empty.
assert!(
xml.contains("status_code=\"200\"><App>"),
"no whitespace between <root> and the first <App>: {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}"
);
}
} }
@@ -108,6 +108,11 @@ pub struct LaunchSession {
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4). /// 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). /// `None` if the address could not be captured (then RTSP falls back to launch-present only).
pub peer_ip: Option<std::net::IpAddr>, pub peer_ip: Option<std::net::IpAddr>,
/// 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. /// Shared control-plane state used as the axum app state.
+129 -5
View File
@@ -126,15 +126,57 @@ async fn h_launch(
peer: Option<Extension<PeerCertFingerprint>>, peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>, addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>, Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse { ) -> Response {
if !peer_is_paired(&peer, &st) { if !peer_is_paired(&peer, &st) {
tracing::warn!("launch rejected — client is not paired"); 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) { match launch(&st, &q) {
Ok(mut session) => { Ok(mut session) => {
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP. // 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.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); *st.launch.lock().unwrap() = Some(session);
tracing::info!( tracing::info!(
w = session.width, w = session.width,
@@ -144,11 +186,11 @@ async fn h_launch(
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}", "launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip st.host.local_ip
); );
xml(session_url_xml(&st, "gamesession")) xml(session_url_xml(&st, "gamesession")).into_response()
} }
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "launch failed"); 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<String, String>) -> Result<LaunchSession>
height, height,
fps, fps,
appid, 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)) 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<LiveGs>,
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 { fn session_url_xml(st: &AppState, tag: &str) -> String {
format!( format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n", "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
@@ -349,4 +434,43 @@ mod tests {
"a non-pinned cert stays rejected" "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
));
}
} }
+192 -53
View File
@@ -286,13 +286,19 @@ fn open_gs_virtual_source(
std::sync::atomic::AtomicBool::new(false), std::sync::atomic::AtomicBool::new(false),
)) ))
}); });
let vout = vd let vout = crate::vdisplay::registry::acquire(
.create(punktfunk_core::Mode { &mut vd,
punktfunk_core::Mode {
width: cfg.width, width: cfg.width,
height: cfg.height, height: cfg.height,
refresh_hz: cfg.fps, 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 // 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 // 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 // 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<u8>]) -> std::io::Result<()> {
Ok(()) 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<u8>, FrameType)>,
ts: u32,
}
/// Packetizer thread: turns each [`RawFrame`]'s access units into wire datagrams (data + ReedSolomon
/// 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<RawFrame>,
tx: std::sync::mpsc::SyncSender<PacketBatch>,
mut pk: VideoPacketizer,
goodput: Arc<std::sync::atomic::AtomicU64>,
) -> 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 /// 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 /// `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 /// (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 // 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). // Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
crate::punktfunk1::boost_thread_priority(false); crate::punktfunk1::boost_thread_priority(false);
// Chunk pacing: 16 packets per burst, bursts spread across the send budget. // Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number
const PACE_CHUNK: usize = 16; // 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 budget = frame_interval.mul_f32(0.75);
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let mut sent: u64 = 0; let mut sent: u64 = 0;
@@ -434,17 +508,21 @@ fn spawn_sender(
if n == 0 { if n == 0 {
continue; 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(); 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) { if let Err(e) = sendmmsg_all(&sock, chunk) {
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream"); tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
running.store(false, Ordering::SeqCst); running.store(false, Ordering::SeqCst);
return; return;
} }
sent += chunk.len() as u64; sent += chunk.len() as u64;
// Sleep toward the next chunk's deadline; skip sub-500µs sleeps (jitter). // Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter).
let target = start + per_chunk.mul_f64((i + 1) as f64); let target = start + per_step.mul_f64((i + 1) as f64);
if let Some(ahead) = target.checked_duration_since(Instant::now()) { if let Some(ahead) = target.checked_duration_since(Instant::now()) {
if ahead >= Duration::from_micros(500) { if ahead >= Duration::from_micros(500) {
std::thread::sleep(ahead); std::thread::sleep(ahead);
@@ -518,7 +596,7 @@ fn stream_body(
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(20); .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 // 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 // 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 sent_batches: u64 = 0;
let mut dropped_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 // Three-stage pipeline so FEC packetization never blocks encode: `encode loop → [raw AUs] →
// slow send can buffer one frame while the next encodes; beyond that the NEWEST batch is // packetizer (FEC/RS) → [wire batch] → paced sender`, each stage on its own thread joined by a
// dropped (the client recovers via FEC/RFI) rather than ever stalling the encode loop. // 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::<PacketBatch>(2); let (batch_tx, batch_rx) = std::sync::mpsc::sync_channel::<PacketBatch>(2);
spawn_sender( spawn_sender(
sock.try_clone().context("clone video socket")?, sock.try_clone().context("clone video socket")?,
@@ -549,12 +633,14 @@ fn stream_body(
running.clone(), running.clone(),
drop_pct, drop_pct,
)?; )?;
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(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, // 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). // to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
let perf = crate::config::config().perf; let perf = crate::config::config().perf;
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) = let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut uniq) =
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32); (0u128, 0u128, 0u128, 0u128, 0u32);
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors // 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 // 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. // 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<u32> = None; let mut sid: Option<u32> = None;
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) = let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new()); (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut bytes_win: u64 = 0;
let mut last_dropped_batches: u64 = 0; let mut last_dropped_batches: u64 = 0;
// Absolute next-frame deadline — the single pacing clock for the loop. // Absolute next-frame deadline — the single pacing clock for the loop.
let mut next_frame = Instant::now(); let mut next_frame = Instant::now();
@@ -580,6 +665,22 @@ fn stream_body(
const MAX_REBUILDS: u32 = 5; const MAX_REBUILDS: u32 = 5;
let mut rebuilds: u32 = 0; 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<Instant> = 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) { while running.load(Ordering::SeqCst) {
let tick = Instant::now(); let tick = Instant::now();
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is // 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")?; .context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi; supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe(); enc.request_keyframe();
last_keyframe = Some(Instant::now());
next_frame = Instant::now(); next_frame = Instant::now();
tracing::info!("gamestream: source rebuilt — stream continues"); tracing::info!("gamestream: source rebuilt — stream continues");
continue; continue;
@@ -654,58 +756,71 @@ fn stream_body(
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder // 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 // 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. // 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() { if let Some((first, last)) = rfi_range.lock().unwrap().take() {
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR // 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)) { 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. // immediately instead of waiting for the next GOP boundary.
if force_idr.swap(false, Ordering::SeqCst) { 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")?; enc.submit(&frame).context("encoder submit")?;
let t_enc = tick.elapsed(); let t_enc = tick.elapsed();
// 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct. // 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 ts = (stream_start.elapsed().as_secs_f64() * 90_000.0) as u32;
let mut batch: Vec<Vec<u8>> = 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<u8>, FrameType)> = Vec::new();
while let Some(au) = enc.poll().context("encoder poll")? { while let Some(au) = enc.poll().context("encoder poll")? {
let ft = if au.keyframe { let ft = if au.keyframe {
FrameType::Idr FrameType::Idr
} else { } else {
FrameType::P FrameType::P
}; };
batch.extend(pk.packetize(&au.data, ft, ts)); aus.push((au.data, ft));
} }
let t_pkt = tick.elapsed(); let t_pkt = tick.elapsed();
// Hand the frame's packets to the send thread; never block here. A full queue means // Hand the frame's AUs to the pipeline; never block here. A full queue means the pipeline
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding. // (packetizer, or the paced sender behind it) is behind — drop this frame (FEC/RFI covers the
let n = batch.len(); // client) and keep encoding, so a downstream stall can never cap the encode rate.
// Goodput this window = bytes actually queued to the sender (a dropped batch never reaches if !aus.is_empty() {
// the wire, so it's excluded). Summed only when measuring, to keep the idle path free. match raw_tx.try_send(RawFrame { aus, ts }) {
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) {
Ok(()) => { Ok(()) => {
sent_batches += 1; sent_batches += 1;
bytes_win += batch_bytes;
} }
Err(std::sync::mpsc::TrySendError::Full(_)) => { Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped_batches += 1; dropped_batches += 1;
recover_after_drop = true; // re-anchor the reference chain on the next frame
if dropped_batches.is_power_of_two() { 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(_)) => { 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 t_send = tick.elapsed();
let cap_us = t_cap.as_micros(); let cap_us = t_cap.as_micros();
let enc_us = (t_enc - t_cap).as_micros(); let enc_us = (t_enc - t_cap).as_micros();
let pkt_us = (t_pkt - t_enc).as_micros(); // `poll` = drain the encoder's AUs; `enqueue` = hand-off to the pipeline. FEC/packetize
let send_us = (t_send - t_pkt).as_micros(); // 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_cap = mx_cap.max(cap_us);
mx_enc = mx_enc.max(enc_us); mx_enc = mx_enc.max(enc_us);
mx_pkt = mx_pkt.max(pkt_us); mx_pkt = mx_pkt.max(poll_us);
mx_send = mx_send.max(send_us); mx_send = mx_send.max(enqueue_us);
mx_pkts = mx_pkts.max(n);
v_cap.push(cap_us as u32); v_cap.push(cap_us as u32);
v_enc.push(enc_us as u32); v_enc.push(enc_us as u32);
v_pkt.push(pkt_us as u32); v_pkt.push(poll_us as u32);
v_send.push(send_us as u32); v_send.push(enqueue_us as u32);
} }
fps_count += 1; fps_count += 1;
if fps_t.elapsed() >= Duration::from_secs(1) { if fps_t.elapsed() >= Duration::from_secs(1) {
let secs = fps_t.elapsed().as_secs_f64(); 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 { if perf {
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device // Max µs/stage this second on the ENCODE loop: cap=drain channel, enc=submit
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new // (zero-copy device copy + NVENC), pkt=poll (AU drain), send=enqueue to the pipeline.
// captured frames (vs re-encoded). `pkts`=max packets in one frame (IDR spike). // 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!( tracing::info!(
fps = fps_count, fps = fps_count,
uniq, uniq,
@@ -740,7 +862,6 @@ fn stream_body(
pkt_us = mx_pkt, pkt_us = mx_pkt,
send_us = mx_send, send_us = mx_send,
cap_us = mx_cap, cap_us = mx_cap,
max_pkts = mx_pkts,
"video: streaming (perf)" "video: streaming (perf)"
); );
} else { } else {
@@ -753,7 +874,7 @@ fn stream_body(
} }
// Web-console capture: build the aggregated sample. The host send side exposes no // 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 // 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() { if stats.is_armed() {
let session_id = *sid.get_or_insert_with(|| { let session_id = *sid.get_or_insert_with(|| {
stats.register_session( stats.register_session(
@@ -792,7 +913,7 @@ fn stream_body(
], ],
fps: (uniq as f64 / secs) as f32, fps: (uniq as f64 / secs) as f32,
repeat_fps: (fps_count.saturating_sub(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, bitrate_kbps: cfg.bitrate_kbps,
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32, frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
packets_dropped: 0, packets_dropped: 0,
@@ -805,13 +926,11 @@ fn stream_body(
mx_enc = 0; mx_enc = 0;
mx_pkt = 0; mx_pkt = 0;
mx_send = 0; mx_send = 0;
mx_pkts = 0;
uniq = 0; uniq = 0;
v_cap.clear(); v_cap.clear();
v_enc.clear(); v_enc.clear();
v_pkt.clear(); v_pkt.clear();
v_send.clear(); v_send.clear();
bytes_win = 0;
last_dropped_batches = dropped_batches; last_dropped_batches = dropped_batches;
fps_count = 0; fps_count = 0;
fps_t = Instant::now(); fps_t = Instant::now();
@@ -889,4 +1008,24 @@ mod tests {
assert_eq!(got, 3 * PER_FRAME); assert_eq!(got, 3 * PER_FRAME);
assert!(running.load(Ordering::SeqCst), "no spurious client-gone"); 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);
}
} }
+36
View File
@@ -418,6 +418,20 @@ fn real_main() -> Result<()> {
allow_pairing: true, allow_pairing: true,
pairing_pin: None, pairing_pin: None,
paired_store: 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::<u64>().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. // 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)> { fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
let mut opts = mgmt::Options::default(); let mut opts = mgmt::Options::default();
let mut native_port: u16 = 9777; // the native plane always runs now 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<u16> = std::env::var("PUNKTFUNK_DATA_PORT")
.ok()
.and_then(|s| s.parse().ok());
let mut open = false; let mut open = false;
let mut gamestream = false; let mut gamestream = false;
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so // 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() .parse()
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))? .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 // Opt into the GameStream/Moonlight-compat planes (off by default — they carry the
// inherent on-path #5/#9 weaknesses; only for a trusted LAN). // inherent on-path #5/#9 weaknesses; only for a trusted LAN).
"--gamestream" | "--moonlight" => gamestream = true, "--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 // 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. // assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
mgmt_port: opts.bind.port(), mgmt_port: opts.bind.port(),
data_port,
}; };
Ok((opts, native, gamestream)) Ok((opts, native, gamestream))
} }
@@ -703,6 +731,10 @@ SERVE OPTIONS:
reuse, security-review #5/#9); enable only on a TRUSTED LAN 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 no-op (the native punktfunk/1 plane always runs in `serve` now)
--native-port <PORT> native QUIC port (default 9777) --native-port <PORT> native QUIC port (default 9777)
--data-port <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 — --open disable mandatory native pairing (default: pairing REQUIRED —
an open host any LAN device can stream from is insecure) an open host any LAN device can stream from is insecure)
@@ -714,6 +746,10 @@ PUNKTFUNK1-HOST OPTIONS:
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0) --max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits --max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
in the accept queue; 0 = unlimited (default: 4) in the accept queue; 0 = unlimited (default: 4)
--data-port <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 --allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
pair=optional. Default: pairing REQUIRED — the host rejects pair=optional. Default: pairing REQUIRED — the host rejects
unpaired clients and logs a 4-digit pairing PIN at startup; unpaired clients and logs a 4-digit pairing PIN at startup;
+383
View File
@@ -156,6 +156,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_compositors)) .routes(routes!(list_compositors))
.routes(routes!(list_gpus)) .routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference)) .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_status))
.routes(routes!(get_local_summary)) .routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients)) .routes(routes!(list_paired_clients))
@@ -210,6 +215,7 @@ pub fn openapi_json() -> String {
tags( tags(
(name = "host", description = "Host identity, capabilities, and liveness"), (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 = "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 = "clients", description = "Paired Moonlight client management"),
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"), (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"), (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, pin_pending: bool,
/// Native pairing knocks awaiting the operator's approval (count only). /// Native pairing knocks awaiting the operator's approval (count only).
pending_approvals: u32, 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. /// A paired (certificate-pinned) Moonlight client.
@@ -954,6 +964,304 @@ async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response
Json(gpu_state()).into_response() Json(gpu_state()).into_response()
} }
// ---------------------------------------------------------------------------------------
// Display management (design/display-management.md)
// ---------------------------------------------------------------------------------------
/// One preset's human-facing description + the fields it expands to, so the console can render a
/// preset picker with an accurate "what this does" preview without hardcoding the expansion.
#[derive(Serialize, ToSchema)]
struct PresetInfo {
/// The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).
id: String,
/// One-line story shown next to the option.
summary: String,
/// The effective policy this preset expands to (the same fields a `custom` policy carries).
fields: crate::vdisplay::policy::EffectivePolicy,
}
/// Full display-management state for the console: the stored policy, every preset's expansion, the
/// resolved effective policy, and which options this build actually enforces yet (Stage 0 wires
/// keep-alive linger + topology; the rest are stored but not yet acted on).
#[derive(Serialize, ToSchema)]
struct DisplaySettingsState {
/// The stored policy (preset + custom fields), or the built-in default when unconfigured.
settings: crate::vdisplay::policy::DisplayPolicy,
/// True once a `display-settings.json` exists (the console has configured this host).
configured: bool,
/// The effective (preset-expanded) policy currently in force.
effective: crate::vdisplay::policy::EffectivePolicy,
/// Every named preset and what it expands to (for the picker's preview).
presets: Vec<PresetInfo>,
/// Option names this build enforces right now. 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<String>,
}
fn preset_summary(id: &str) -> &'static str {
match id {
"default" => "Today's behavior: a short linger absorbs reconnects, the streamed output is the sole desktop, extra clients get their own view.",
"gaming-rig" => "Dedicated couch/headless box: the game and its display survive disconnects; whoever connects takes the box over.",
"shared-desktop" => "A desktop you also use in person: never blank the real monitors, never keep ghost displays, concurrent viewers each get a view.",
"hotdesk" => "One user at a time with fast reattach; a second user is told the box is busy; each device+resolution keeps its own scaling.",
"workstation" => "Multi-monitor daily driver: your displays come back exactly where you arranged them, per-client identity, exclusive.",
_ => "",
}
}
fn display_settings_state() -> DisplaySettingsState {
use crate::vdisplay::policy::{self, Preset};
let store = policy::prefs();
let settings = store.get();
let configured = store.configured().is_some();
let presets = [
("default", Preset::Default),
("gaming-rig", Preset::GamingRig),
("shared-desktop", Preset::SharedDesktop),
("hotdesk", Preset::Hotdesk),
("workstation", Preset::Workstation),
]
.into_iter()
.filter_map(|(id, p)| {
policy::preset_fields(p).map(|e| PresetInfo {
id: id.to_string(),
summary: preset_summary(id).to_string(),
fields: e,
})
})
.collect();
DisplaySettingsState {
effective: settings.effective(),
settings,
configured,
presets,
enforced: vec![
"keep_alive".into(),
"topology".into(),
"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<DisplaySettingsState> {
Json(display_settings_state())
}
/// Set the display-management policy
///
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
/// running session keeps the display it opened on. `keep_alive: forever` (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<crate::vdisplay::policy::DisplayPolicy>,
) -> 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<u64>,
/// Live sessions holding the display.
sessions: u32,
/// Short client label, when the owner tracks it.
client: Option<String>,
/// 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<u32>,
/// 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<ApiDisplayInfo>,
}
/// Request body for `releaseDisplay`.
#[derive(Deserialize, ToSchema)]
struct ReleaseDisplayRequest {
/// Slot to release (see `state`); omit to release **all** kept displays.
#[serde(default)]
slot: Option<u64>,
}
/// 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<DisplayStateResponse> {
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<ReleaseDisplayRequest>,
) -> Json<ReleaseDisplayResult> {
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 {
/// `{"<identity_slot>": {"x": …, "y": …}}` — where each arranged display's top-left sits.
#[serde(default)]
positions: std::collections::BTreeMap<String, crate::vdisplay::policy::Position>,
}
/// 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<DisplayLayoutRequest>) -> 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 /// Live host status
#[utoipa::path( #[utoipa::path(
get, get,
@@ -1026,6 +1334,11 @@ async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummar
native_paired_clients, native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(), pin_pending: st.app.pairing.pin.awaiting_pin(),
pending_approvals, pending_approvals,
kept_displays: crate::vdisplay::registry::snapshot()
.displays
.iter()
.filter(|d| d.state == "lingering" || d.state == "pinned")
.count() as u32,
}) })
} }
@@ -2257,6 +2570,7 @@ mod tests {
fps: 120, fps: 120,
appid: 1, appid: 1,
peer_ip: None, peer_ip: None,
owner_fp: None,
}); });
state.streaming.store(true, Ordering::SeqCst); state.streaming.store(true, Ordering::SeqCst);
@@ -2383,6 +2697,7 @@ mod tests {
fps: 60, fps: 60,
appid: 1, appid: 1,
peer_ip: None, peer_ip: None,
owner_fp: None,
}); });
let del = axum::http::Request::delete("/api/v1/session") let del = axum::http::Request::delete("/api/v1/session")
@@ -2473,6 +2788,74 @@ mod tests {
.unwrap() .unwrap()
} }
/// The display-management GET surface (presets + effective + the enforced-axes list). READ-ONLY
/// on purpose: `prefs()` is a process-global `OnceLock`, so a PUT here would clobber it and race
/// other tests running in the same process. `keep_alive: forever` (gaming-rig) is now accepted
/// (not rejected) — that acceptance is covered on-glass (`.116`) + by the pure `policy` tests, and
/// the `forever` value is read off the surfaced preset below without writing.
#[tokio::test]
async fn display_settings_surface() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
assert_eq!(status, StatusCode::OK);
let presets = body["presets"].as_array().expect("presets array");
assert_eq!(
presets.len(),
5,
"all five named presets are surfaced for the console picker"
);
assert!(
body["effective"]["keep_alive"].is_object(),
"the effective policy is echoed"
);
// gaming-rig surfaces keep_alive: forever (no longer rejected) — read it off the preset list.
let gaming = presets
.iter()
.find(|p| p["id"] == "gaming-rig")
.expect("gaming-rig preset surfaced");
assert_eq!(
gaming["fields"]["keep_alive"]["mode"], "forever",
"gaming-rig is keep_alive: forever"
);
let enforced: Vec<&str> = 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] #[tokio::test]
async fn native_pairing_arm_show_and_unpair() { async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new( let np = Arc::new(
+268 -26
View File
@@ -75,6 +75,41 @@ pub struct Punktfunk1Options {
pub pairing_pin: Option<String>, pub pairing_pin: Option<String>,
/// Paired-clients store path override (tests); `None` = the default config path. /// Paired-clients store path override (tests); `None` = the default config path.
pub paired_store: Option<std::path::PathBuf>, pub paired_store: Option<std::path::PathBuf>,
/// 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<u16>,
/// 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<std::time::Duration>,
}
/// 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<u16>) -> 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. /// 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 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). /// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
pub mgmt_port: u16, 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<u16>,
} }
/// Options for the native host when the unified `serve --native` runs it: real virtual capture, /// 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`. /// overflow clients wait in the accept queue. Override with `--max-concurrent`.
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4; 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::time::Duration> {
std::env::var("PUNKTFUNK_IDLE_TIMEOUT_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map(std::time::Duration::from_millis)
}
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options { pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
Punktfunk1Options { Punktfunk1Options {
port: cfg.port, port: cfg.port,
@@ -165,6 +214,8 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
allow_pairing: false, allow_pairing: false,
pairing_pin: None, pairing_pin: None,
paired_store: 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)")?; .context("load host identity (~/.config/punktfunk)")?;
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem) let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
.map_err(|e| anyhow!("cert fingerprint: {e}"))?; .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(), ([0, 0, 0, 0], opts.port).into(),
&identity.cert_pem, &identity.cert_pem,
&identity.key_pem, &identity.key_pem,
opts.idle_timeout.unwrap_or(endpoint::DEFAULT_IDLE_TIMEOUT),
) )
.map_err(|e| anyhow!("QUIC server endpoint: {e}"))?; .map_err(|e| anyhow!("QUIC server endpoint: {e}"))?;
tracing::info!( tracing::info!(
@@ -341,6 +393,18 @@ pub(crate) async fn serve(
/// connects and never finishes the handshake would otherwise wedge the host for everyone. /// 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); 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 /// 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 /// (`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. /// 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 source = opts.source;
let frames = opts.frames; let frames = opts.frames;
let data_port = opts.data_port;
let handshake = async { 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!( anyhow::ensure!(
hello.abi_version == punktfunk_core::WIRE_VERSION, hello.abi_version == punktfunk_core::WIRE_VERSION,
"wire version mismatch: client {} host {}", "wire version mismatch: client {} host {}",
@@ -684,6 +749,74 @@ async fn serve_session(
"video codec negotiated" "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 <name>")
// 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) crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
.context("client-requested mode")?; .context("client-requested mode")?;
@@ -797,10 +930,12 @@ async fn serve_session(
"encode chroma" "encode chroma"
); );
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport). // Reserve the data-plane UDP socket up front and HOLD it through streaming (no
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; // bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
let udp_port = probe.local_addr()?.port(); // `--data-port` yields `direct = true` (stream straight to the client's reported address,
drop(probe); // 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]; let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key); 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?) let start = Start::decode(&io::read_msg(&mut recv).await?)
.map_err(|e| anyhow!("Start decode: {e:?}"))?; .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) tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
.await .await
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; .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. // Stop signal: stream duration elapsed or the client went away.
let stop = Arc::new(AtomicBool::new(false)); 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 stop = stop.clone();
let quit = quit.clone();
let conn = conn.clone(); let conn = conn.clone();
tokio::spawn(async move { 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); 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::<String>())
.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 // 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 // → 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 // (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 crate::encode::ChromaFormat::Yuv420
}; };
let stop_stream = stop.clone(); 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 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 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 // 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()); .unwrap_or_else(|| conn.remote_address().ip().to_string());
let result: Result<()> = async { let result: Result<()> = async {
tokio::task::spawn_blocking(move || -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> {
// Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED // Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
// source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host // for the client's punch, then stream to its OBSERVED source, so video traverses a
// can be on different subnets; control + side planes ride the client-initiated QUIC, but // NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
// the raw video UDP needs the client to open the path first). Falls back to the // QUIC, but the raw video UDP needs the client to open the path first); falls back to
// client-reported address for clients that don't punch (flat-LAN, unchanged). // the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed
let (transport, punched) = match UdpTransport::connect_via_punch( // `--data-port` (`direct`), skip the punch-wait and stream straight to the reported
&format!("0.0.0.0:{udp_port}"), // address — the operator declared a reachable, firewall-opened port, so there's no
&client_udp.to_string(), // punch-timeout to pay. (Direct trusts the reported port: it can't cross a client-side
std::time::Duration::from_millis(2500), // 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, Ok(v) => v,
Err(e) => { Err(e) => {
// Surface the failure here directly: a data-plane bind error would otherwise be // 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). // 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"); return Err(anyhow::Error::new(e)).context("bind data plane");
} }
}; };
tracing::info!( tracing::info!(
%client_udp, %client_udp,
udp_port,
direct,
punched, punched,
"data plane bound (punched=true → streaming to the client's observed source; \ "data plane bound (direct=true → fixed --data-port, streaming to the reported \
false → no hole-punch seen, using the reported address)" 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)) let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?; .map_err(|e| anyhow!("host session: {e:?}"))?;
@@ -1212,6 +1386,7 @@ async fn serve_session(
mode, mode,
seconds, seconds,
stop: stop_stream, stop: stop_stream,
quit: quit_stream,
reconfig: reconfig_rx, reconfig: reconfig_rx,
keyframe: keyframe_rx, keyframe: keyframe_rx,
compositor, compositor,
@@ -2751,6 +2926,9 @@ struct SessionContext {
seconds: u32, seconds: u32,
/// Session stop flag (set on disconnect / reconnect-preempt). /// Session stop flag (set on disconnect / reconnect-preempt).
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
/// 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<AtomicBool>,
/// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode. /// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>, reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
/// Client decode-recovery keyframe requests. /// Client decode-recovery keyframe requests.
@@ -2810,6 +2988,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
mode, mode,
seconds, seconds,
stop, stop,
quit,
reconfig, reconfig,
keyframe, keyframe,
compositor, compositor,
@@ -2860,7 +3039,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); .then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
let (mut capturer, mut enc, mut frame, mut interval) = 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). // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
drop(_idd_setup_guard); drop(_idd_setup_guard);
@@ -3028,6 +3207,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bitrate_kbps, bitrate_kbps,
bit_depth, bit_depth,
plan, plan,
&quit,
)?; )?;
Ok((new_vd, pipe)) 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 // 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 // the switch as accepted, so a rebuild failure must not kill an otherwise
// healthy session — keep streaming the current mode and log instead. // 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) => { Ok(next_pipe) => {
(capturer, enc, frame, interval) = next_pipe; (capturer, enc, frame, interval) = next_pipe;
cur_mode = new_mode; cur_mode = new_mode;
@@ -3192,6 +3372,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bitrate_kbps, bitrate_kbps,
bit_depth, bit_depth,
plan, plan,
&quit,
) { ) {
Ok(p) => break p, Ok(p) => break p,
Err(e2) => { Err(e2) => {
@@ -3418,6 +3599,7 @@ fn build_pipeline_with_retry(
bitrate_kbps: u32, bitrate_kbps: u32,
bit_depth: u8, bit_depth: u8,
plan: crate::session_plan::SessionPlan, plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
) -> Result<Pipeline> { ) -> Result<Pipeline> {
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // ~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 // 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; const MAX_ATTEMPTS: u32 = 8;
let mut backoff = std::time::Duration::from_millis(500); let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS { 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) => { Ok(pipe) => {
if attempt > 1 { if attempt > 1 {
tracing::info!(attempt, "pipeline up after retry"); tracing::info!(attempt, "pipeline up after retry");
@@ -3507,8 +3689,15 @@ fn build_pipeline(
bitrate_kbps: u32, bitrate_kbps: u32,
bit_depth: u8, bit_depth: u8,
plan: crate::session_plan::SessionPlan, plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
) -> Result<Pipeline> { ) -> Result<Pipeline> {
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 // 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 // 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 // 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); 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] #[test]
fn compositor_resolution_precedence() { fn compositor_resolution_precedence() {
use crate::vdisplay::Compositor::*; use crate::vdisplay::Compositor::*;
@@ -3756,10 +3982,18 @@ mod tests {
/// End-to-end through the C ABI — the exact contract platform clients (Swift) link: /// End-to-end through the C ABI — the exact contract platform clients (Swift) link:
/// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) → /// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) →
/// `punktfunk_connection_next_au` pulls verified frames → `punktfunk_connection_send_input` /// `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 /// enqueues → `punktfunk_connection_close`. Three sequential sessions against ONE host
/// process prove the persistent listener, and a wrong pin is rejected. /// process prove the persistent listener, and a wrong pin is rejected.
#[test] #[test]
fn c_abi_connection_roundtrip() { fn c_abi_connection_roundtrip() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::abi::{ use punktfunk_core::abi::{
punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode, punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode,
punktfunk_connection_send_input, punktfunk_connection_send_input,
@@ -3778,6 +4012,8 @@ mod tests {
allow_pairing: false, allow_pairing: false,
pairing_pin: None, pairing_pin: None,
paired_store: None, paired_store: None,
data_port: None,
idle_timeout: None,
}) })
}); });
std::thread::sleep(std::time::Duration::from_millis(500)); 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. /// admitted to a session with no PIN and no reconnect.
#[test] #[test]
fn delegated_approval_admits_after_knock() { 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::client::NativeClient;
use punktfunk_core::quic::endpoint; use punktfunk_core::quic::endpoint;
@@ -3972,6 +4209,8 @@ mod tests {
allow_pairing: false, allow_pairing: false,
pairing_pin: None, pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle 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 0, // no mgmt API in this test → advertise no `mgmt` mDNS port
np_host, np_host,
@@ -4055,6 +4294,7 @@ mod tests {
/// identity gets a session on a pairing-required host; an anonymous client does not. /// identity gets a session on a pairing-required host; an anonymous client does not.
#[test] #[test]
fn pairing_ceremony_and_gate() { 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::client::NativeClient;
use punktfunk_core::quic::endpoint; use punktfunk_core::quic::endpoint;
@@ -4070,6 +4310,8 @@ mod tests {
allow_pairing: false, allow_pairing: false,
pairing_pin: Some("4321".into()), pairing_pin: Some("4321".into()),
paired_store: Some(test_paired_path()), paired_store: Some(test_paired_path()),
data_port: None,
idle_timeout: None,
}) })
}); });
std::thread::sleep(std::time::Duration::from_millis(500)); std::thread::sleep(std::time::Duration::from_millis(500));
+117 -17
View File
@@ -64,6 +64,43 @@ pub trait VirtualDisplay: Send {
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual /// 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. /// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {} 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<u32> {
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<Box<dyn FnOnce() + Send>> {
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). /// Compositors punktfunk knows how to drive (plan §6).
@@ -403,21 +440,11 @@ pub fn apply_session_env(active: &ActiveSession) {
if active.kind == ActiveKind::DesktopGnome { if active.kind == ActiveKind::DesktopGnome {
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1"); std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
} }
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so // Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// the panels + windows land on the streamed surface, not an unstreamed real output (the // [`effective_topology`] directly at create time — the console policy, else the legacy
// auto-detected desktop path *is* "stream this desktop"). Default-on for the auto path; an // `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
// explicit `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` still wins. // auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
match active.kind { // mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
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");
}
_ => {}
}
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn apply_session_env(_active: &ActiveSession) {} pub fn apply_session_env(_active: &ActiveSession) {}
@@ -723,14 +750,87 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(()) 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 // 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. // backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/gamescope.rs"] #[path = "vdisplay/linux/gamescope.rs"]
mod gamescope; mod gamescope;
#[cfg(target_os = "windows")] // Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
#[path = "vdisplay/windows/identity.rs"] // 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; 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")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin.rs"] #[path = "vdisplay/linux/kwin.rs"]
mod kwin; mod kwin;
@@ -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<AtomicBool>,
/// 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<Arc<AtomicBool>>),
/// `reject`: refuse with this reason (host-busy + live mode + client label).
Reject(String),
}
fn table() -> &'static Mutex<Vec<LiveSession>> {
static T: OnceLock<Mutex<Vec<LiveSession>>> = 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<Arc<AtomicBool>> {
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<Arc<AtomicBool>> {
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<AtomicBool>,
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<u8>, 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))
));
}
}
@@ -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-<id>`; 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 `<config>/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<Entry>,
}
#[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::<Store>(&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<DisplayIdentityMap> {
static MAP: OnceLock<Mutex<DisplayIdentityMap>> = 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<u32> {
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"));
}
}
@@ -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<u32>,
/// 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<Placement> {
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<u32>, 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");
}
}
@@ -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"
);
}
}
}
+234 -71
View File
@@ -67,13 +67,42 @@ const VOUT_NAME: &str = "punktfunk";
/// event (deprecated only since v6) for the node id, so cap the bind at 5. /// event (deprecated only since v6) for the node id, so cap the bind at 5.
const MAX_VERSION: u32 = 5; const MAX_VERSION: u32 = 5;
/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up /// The KWin virtual-display driver. Carries the connecting client's cert fingerprint (set before
/// its own Wayland connection/thread that owns the resulting output. /// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME
pub struct KwinDisplay; /// (`Virtual-punktfunk-<id>`) — 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<u32>,
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`.
last_name: Option<String>,
/// 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<Box<dyn FnOnce() + Send>>,
}
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 { impl KwinDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(KwinDisplay) Ok(KwinDisplay::default())
} }
} }
@@ -82,14 +111,61 @@ impl VirtualDisplay for KwinDisplay {
"kwin" "kwin"
} }
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
self.client_fp = fingerprint;
}
fn last_identity_slot(&self) -> Option<u32> {
self.last_slot
}
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
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.<name>.position.<x>,<y>`.
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<VirtualOutput> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id →
// `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, 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::<Result<u32, String>>(); let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone(); let stop_thread = stop.clone();
let (width, height) = (mode.width, mode.height); let (width, height) = (mode.width, mode.height);
let name_thread = name.clone();
thread::Builder::new() thread::Builder::new()
.name("punktfunk-kwin-vout".into()) .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")?; .context("spawn KWin virtual-output thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { 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 — // 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. // the source runs 60 Hz and the encoder downsamples — so carry the requested rate through.
let achieved_hz = if mode.refresh_hz > 60 { 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 { } else {
mode.refresh_hz mode.refresh_hz
}; };
// Make our streamed output the SOLE desktop: plasmashell + windows land on the surface we // Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
// stream, not on the headless session's `kwin --virtual` bootstrap output (otherwise the // `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
// client sees only the wallpaper of an empty extended output). Opt-in // `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
// (PUNKTFUNK_KWIN_VIRTUAL_PRIMARY), mirroring the Mutter backend's PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY. // plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
let restore = if virtual_primary_enabled() { // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
apply_virtual_primary() use crate::vdisplay::policy::Topology;
} else { let disabled = match crate::vdisplay::effective_topology() {
Vec::new() 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<dyn FnOnce() + Send>
});
// 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 { Ok(VirtualOutput {
node_id, node_id,
remote_fd: None, remote_fd: None,
preferred_mode: Some((mode.width, mode.height, achieved_hz)), 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<String> = 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 /// 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-<VOUT_NAME>`, /// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-<VOUT_NAME>`,
/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually /// 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), /// 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 /// 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. /// paces the encoder to the *achieved* rate, not the requested one.
fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 { fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
let output = format!("Virtual-{VOUT_NAME}"); let output = format!("Virtual-{name}");
let mhz = hz.saturating_mul(1000); let mhz = hz.saturating_mul(1000);
let run = |arg: String| { let run = |arg: String| {
std::process::Command::new("kscreen-doctor") std::process::Command::new("kscreen-doctor")
@@ -213,26 +323,17 @@ fn read_active_refresh(output: &str) -> Option<u32> {
Some(hz.round() as u32) Some(hz.round() as u32)
} }
/// Opt-in: make the per-session virtual output the sole desktop. Off by default — a host with no /// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
/// competing output (or one that wants the bootstrap kept) is unaffected; the headless KDE appliance /// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-<id>`. Group membership (§6.1) is
/// (run-headless-kde.sh's `kwin --virtual` bootstrap + our streamed output) sets it so the desktop /// recognised by this prefix, so we never have to thread the live set through the backend.
/// renders on the streamed surface, not the bootstrap. Mirrors the Mutter backend's gate. const MANAGED_PREFIX: &str = "Virtual-punktfunk";
fn virtual_primary_enabled() -> bool {
std::env::var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless /// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's
/// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j` /// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable.
/// (same source as [`read_active_refresh`]). /// **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<String> { fn other_enabled_outputs() -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}");
let out = match std::process::Command::new("kscreen-doctor") let out = match std::process::Command::new("kscreen-doctor")
.arg("-j") .arg("-j")
.output() .output()
@@ -248,22 +349,49 @@ fn other_enabled_outputs() -> Vec<String> {
.and_then(|o| o.as_array()) .and_then(|o| o.as_array())
.map(|outs| { .map(|outs| {
outs.iter() outs.iter()
.filter(|o| { .filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false) .filter_map(|o| o.get("name").and_then(|n| n.as_str()))
&& o.get("name").and_then(|n| n.as_str()) != Some(ours.as_str()) .filter(|n| !n.starts_with(MANAGED_PREFIX))
}) .map(String::from)
.filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from))
.collect() .collect()
}) })
.unwrap_or_default() .unwrap_or_default()
} }
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so it becomes the sole /// True if any managed group member (the [`MANAGED_PREFIX`] family) is ALREADY the KWin primary —
/// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the /// first-slot-wins support (§6.1) so a later exclusive session doesn't steal primary from the group's
/// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly /// 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::<serde_json::Value>(&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. /// showing only the wallpaper) rather than failing the session.
fn apply_virtual_primary() -> Vec<String> { fn apply_virtual_primary(name: &str) -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}"); let ours = format!("Virtual-{name}");
let kscreen = |args: &[String]| { let kscreen = |args: &[String]| {
std::process::Command::new("kscreen-doctor") std::process::Command::new("kscreen-doctor")
.args(args) .args(args)
@@ -271,15 +399,20 @@ fn apply_virtual_primary() -> Vec<String> {
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
}; };
// Make ours primary — KWin usually then re-homes the desktop and disables the bootstrap on its // First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
// own. Let that settle, then belt-and-suspenders: disable anything still enabled besides ours so // exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
// the streamed output is unambiguously the sole desktop regardless of KWin's implicit behaviour. // shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
if !kscreen(&[format!("output.{ours}.primary")]) { // bootstrap on its own; the belt-and-suspenders disable below covers the rest.
tracing::warn!( if !a_managed_output_is_primary() {
"KWin: could not set the virtual output primary; client may see only the wallpaper" 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(); let others = other_enabled_outputs();
if !others.is_empty() { if !others.is_empty() {
let args: Vec<String> = others let args: Vec<String> = others
@@ -292,29 +425,33 @@ fn apply_virtual_primary() -> Vec<String> {
others 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 /// 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 { struct StopGuard {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
/// 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<String>,
} }
impl Drop for StopGuard { impl Drop for StopGuard {
fn drop(&mut self) { fn drop(&mut self) {
if !self.restore.is_empty() {
let args: Vec<String> = 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); self.stop.store(true, Ordering::Relaxed);
} }
} }
@@ -388,10 +525,11 @@ impl Dispatch<ScreencastStream, ()> for State {
fn virtual_output_thread( fn virtual_output_thread(
width: u32, width: u32,
height: u32, height: u32,
name: String,
setup_tx: Sender<Result<u32, String>>, setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) { ) {
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. // If we never delivered a node id, report the failure to the waiting opener.
let _ = setup_tx.send(Err(format!("{e:#}"))); let _ = setup_tx.send(Err(format!("{e:#}")));
} }
@@ -431,6 +569,7 @@ pub fn is_available() -> bool {
fn run( fn run(
width: u32, width: u32,
height: u32, height: u32,
name: &str,
setup_tx: &Sender<Result<u32, String>>, setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool, stop: &AtomicBool,
) -> Result<()> { ) -> Result<()> {
@@ -453,7 +592,7 @@ fn run(
// Create the virtual output sized to the client, cursor composited into the stream. // Create the virtual output sized to the client, cursor composited into the stream.
let stream = screencast.stream_virtual_output( let stream = screencast.stream_virtual_output(
VOUT_NAME.to_string(), name.to_string(),
width as i32, width as i32,
height as i32, height as i32,
1.0, // scale (logical == physical) 1.0, // scale (logical == physical)
@@ -522,3 +661,27 @@ fn run(
let _ = conn.flush(); let _ = conn.flush();
Ok(()) 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"]);
}
}
@@ -42,11 +42,19 @@ const CURSOR_EMBEDDED: u32 = 1;
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a /// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
/// keepalive thread owning the D-Bus sessions behind the virtual monitor. /// 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 { impl MutterDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(MutterDisplay) Ok(MutterDisplay {
first_in_group: true,
})
} }
} }
@@ -64,13 +72,18 @@ impl VirtualDisplay for MutterDisplay {
"mutter" "mutter"
} }
fn set_first_in_group(&mut self, first: bool) {
self.first_in_group = first;
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>(); let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone(); let stop_thread = stop.clone();
let first_in_group = self.first_in_group;
thread::Builder::new() thread::Builder::new()
.name("punktfunk-mutter-vout".into()) .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")?; .context("spawn Mutter virtual-output thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { 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 /// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
/// node id, then hold the connection until stopped. /// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a
fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>, mode: Mode) { /// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it).
fn session_thread(
setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>,
mode: Mode,
first_in_group: bool,
) {
let rt = match tokio::runtime::Builder::new_multi_thread() let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1) .worker_threads(1)
.enable_all() .enable_all()
@@ -118,9 +137,30 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
} }
}; };
rt.block_on(async move { rt.block_on(async move {
// Opt-in: snapshot the monitor layout BEFORE the virtual output exists, so we can tell the // Display-management topology (Stage 2): the console policy's level, resolved to a concrete
// new (virtual) connector apart and restore the layout on teardown. Best-effort. // value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
let dc_pre = if virtual_primary_enabled() { // 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 { match display_config().await {
Ok(dc) => match get_state(&dc).await { Ok(dc) => match get_state(&dc).await {
Ok(state) => Some((dc, state)), Ok(state) => Some((dc, state)),
@@ -152,8 +192,12 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
// monitor attached, the virtual output is an empty extended desktop — you stream only the // 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. // wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
if let Some((dc, pre)) = &dc_pre { if let Some((dc, pre)) = &dc_pre {
match make_virtual_primary(dc, mode, pre).await { match make_virtual_primary(dc, mode, pre, exclusive).await {
Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"), Ok(()) => tracing::info!(
exclusive,
"mutter: virtual output set as the primary monitor (physicals {})",
if exclusive { "disabled" } else { "kept" }
),
Err(e) => tracing::warn!( Err(e) => tracing::warn!(
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor" "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<String, Value<'static>>); // connector, mode_id, props type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>); type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
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 /// 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 /// 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 /// 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 /// 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 /// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
/// surface. Restored on teardown. /// 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 pre_conns = connectors(pre);
let deadline = Instant::now() + Duration::from_secs(6); let deadline = Instant::now() + Duration::from_secs(6);
loop { loop {
@@ -437,7 +475,14 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
let Some(vmode) = vmode else { let Some(vmode) = vmode else {
bail!("virtual monitor {vconn} has no usable mode yet"); 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 let _: () = dc
.call( .call(
"ApplyMonitorsConfig", "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 /// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so
/// disables them for the session. This confines the cursor, windows, and keyboard focus to the /// 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 /// 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 /// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
/// vanish). The physical layout is restored on teardown. /// vanish). The physical layout is restored on teardown.
fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> { fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
vec![( vec![(
0, 0,
0, 0,
@@ -474,3 +519,47 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())], 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<ApplyLogical> {
let mut logicals: Vec<ApplyLogical> = 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
}
@@ -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 `<config>/display-settings.json` and editable from the web console.
//! The lifecycle/registry that *acts* on this policy lands in later stages; **Stage 0** (this file
//! plus the mgmt endpoints) stands up the surface and wires the two behaviors the existing code can
//! already express — the Windows monitor linger duration and the Linux "make the streamed output
//! the sole desktop" topology — through it.
//!
//! Precedence, mirroring the GPU preference (`console preference > env pin > default`): a present,
//! valid `display-settings.json` (console-written) **wins**; when it is absent the host keeps its
//! historical env-knob / default behavior untouched ([`DisplayPolicyStore::configured`] returns
//! `None`, and every Stage-0 call site falls back to exactly what it did before). The policy is
//! read at each acquire/teardown (file state, not a startup-frozen env var), so a console change
//! applies to the next connect without a host restart.
//!
//! The pure logic here — preset expansion, [`DisplayPolicy::effective`], the [`KeepAlive`] linger
//! resolution — is unit-tested; the store adds file I/O around it (the `gpu.rs` discipline:
//! private dir, temp-write + atomic rename, in-memory rollback on a failed write).
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
/// survives after the last client session detaches. Serialized as an object tagged on `mode`
/// (`{"mode":"off"}` / `{"mode":"duration","seconds":300}` / `{"mode":"forever"}`) so the web form
/// and the OpenAPI schema stay simple.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum KeepAlive {
/// Tear the display down at session end (today's default on every backend but Windows, which
/// lingers 10 s).
Off,
/// Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect
/// inside the window reuses it.
Duration {
/// Linger window in seconds.
seconds: u32,
},
/// Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).
/// **Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.
Forever,
}
impl Default for KeepAlive {
fn default() -> Self {
// The historical Windows behavior, made explicit; the Linux backends had no linger and map
// `Off`/short-duration onto their (nonexistent) keep-alive as a no-op until the lifecycle stage.
KeepAlive::Duration { seconds: 10 }
}
}
/// Resolved linger for the display lifecycle: teardown immediately, after a fixed window, or never.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Linger {
/// Tear down as soon as the last session leaves.
Immediate,
/// Linger for this window, then tear down.
For(Duration),
/// Never auto-tear-down (Pinned).
Forever,
}
impl KeepAlive {
/// The [`Linger`] this keep-alive resolves to.
pub fn linger(self) -> Linger {
match self {
KeepAlive::Off => Linger::Immediate,
KeepAlive::Duration { seconds } => Linger::For(Duration::from_secs(seconds as u64)),
KeepAlive::Forever => Linger::Forever,
}
}
}
/// What the host does to the box's display topology while managed virtual displays are up.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Topology {
/// Today's behavior, resolved per host at acquire time (see [`super::effective_topology`]):
/// exclusive on Windows and the auto-detected Linux desktop path, extend under an explicit
/// `PUNKTFUNK_COMPOSITOR` pin.
#[default]
Auto,
/// Add the virtual display(s); touch nothing else.
Extend,
/// Make the group's primary virtual display the OS primary; physical outputs stay enabled.
Primary,
/// The managed virtual displays become the only enabled outputs (physical outputs disabled,
/// restored on teardown).
Exclusive,
}
/// Admission when a *different* client connects while a display/session is already live and asks for
/// a different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ModeConflict {
/// Give the new client its own virtual display on the same desktop (today's Linux multi-view).
#[default]
Separate,
/// Stop the existing session(s), tear down / reconfigure, serve the new client.
Steal,
/// Admit the new client at the live display's mode (the honest-downgrade convention).
Join,
/// Refuse the new client with a clear handshake error.
Reject,
}
/// Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored
/// at Stage 0; carriers wired from the identity stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Identity {
/// One identity for everything (today's Linux behavior).
Shared,
/// One identity per paired client cert fingerprint (today's Windows behavior).
#[default]
PerClient,
/// One identity per (client, resolution) — distinct scaling per resolution, at the cost of
/// identity slots.
PerClientMode,
}
/// How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from
/// the multi-monitor stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutMode {
/// Left-to-right in acquire order, top-aligned (deterministic default).
#[default]
AutoRow,
/// Per-identity-slot offsets from [`Layout::positions`] (console-arranged).
Manual,
}
/// A desktop-space offset for a display (top-left origin).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Position {
pub x: i32,
pub y: i32,
}
/// Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by
/// identity-slot id (string keys for stable JSON).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Layout {
#[serde(default)]
pub mode: LayoutMode,
#[serde(default)]
pub positions: BTreeMap<String, Position>,
}
/// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Preset {
/// The explicit fields below define the policy.
#[default]
Custom,
/// Today's behavior, made explicit.
Default,
/// Dedicated headless/couch box: displays + game survive disconnects; whoever connects takes over.
GamingRig,
/// A desktop someone also uses physically: never blank the real monitors, never keep ghosts.
SharedDesktop,
/// One user at a time with fast reattach; a second user is told the box is busy.
Hotdesk,
/// The multi-monitor daily driver: manual arrangement, per-client identity, exclusive.
Workstation,
}
/// The user-facing display-management policy — what `display-settings.json` holds and what the mgmt
/// API GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are
/// ignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a
/// single [`EffectivePolicy`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct DisplayPolicy {
/// Schema version (currently 1) — lets a future field addition migrate rather than reject.
#[serde(default = "one")]
pub version: u32,
#[serde(default)]
pub preset: Preset,
#[serde(default)]
pub keep_alive: KeepAlive,
#[serde(default)]
pub topology: Topology,
#[serde(default)]
pub mode_conflict: ModeConflict,
#[serde(default)]
pub identity: Identity,
#[serde(default)]
pub layout: Layout,
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
#[serde(default = "default_max_displays")]
pub max_displays: u32,
}
fn one() -> u32 {
1
}
fn default_max_displays() -> u32 {
4
}
impl Default for DisplayPolicy {
fn default() -> Self {
// Bit-for-bit today's behavior (the `default` preset expanded), so an unconfigured host reads
// the same policy the Stage-0 call sites already produce.
DisplayPolicy {
version: 1,
preset: Preset::Custom,
keep_alive: KeepAlive::default(),
topology: Topology::Auto,
mode_conflict: ModeConflict::default(),
identity: Identity::default(),
layout: Layout::default(),
max_displays: 4,
}
}
}
/// The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call
/// sites read, and what the mgmt API echoes as the "currently in force" policy. Pure output of
/// [`DisplayPolicy::effective`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct EffectivePolicy {
pub keep_alive: KeepAlive,
pub topology: Topology,
pub mode_conflict: ModeConflict,
pub identity: Identity,
pub layout: Layout,
pub max_displays: u32,
}
impl DisplayPolicy {
/// Resolve to the [`EffectivePolicy`]: a named preset expands to its bundle; `Custom` uses the
/// explicit fields. Pure — the single source of truth shared by the preset docs and the runtime.
pub fn effective(&self) -> EffectivePolicy {
if let Some(mut e) = preset_fields(self.preset) {
// A preset fixes the six behavior fields but honors an explicit manual layout table
// (positions are data, not behavior — the `workstation` preset only sets the *mode*).
if self.preset == Preset::Workstation && !self.layout.positions.is_empty() {
e.layout.positions = self.layout.positions.clone();
}
e
} else {
EffectivePolicy {
keep_alive: self.keep_alive,
topology: self.topology,
mode_conflict: self.mode_conflict,
identity: self.identity,
layout: self.layout.clone(),
max_displays: self.max_displays,
}
}
}
/// Clamp fields to their valid ranges (called on write). `max_displays` to `1..=16` (the
/// pf-vdisplay connector ceiling / a sane Linux bound).
pub fn sanitized(mut self) -> Self {
self.version = 1;
self.max_displays = self.max_displays.clamp(1, 16);
self
}
}
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<String, Position>) -> 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<EffectivePolicy> {
let base = |keep_alive, topology, mode_conflict, identity, layout_mode| EffectivePolicy {
keep_alive,
topology,
mode_conflict,
identity,
layout: Layout {
mode: layout_mode,
positions: BTreeMap::new(),
},
max_displays: 4,
};
Some(match preset {
Preset::Custom => return None,
Preset::Default => base(
KeepAlive::Duration { seconds: 10 },
Topology::Auto,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::GamingRig => base(
KeepAlive::Forever,
Topology::Exclusive,
ModeConflict::Steal,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::SharedDesktop => base(
KeepAlive::Off,
Topology::Extend,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::Hotdesk => base(
KeepAlive::Duration { seconds: 300 },
Topology::Exclusive,
ModeConflict::Reject,
Identity::PerClientMode,
LayoutMode::AutoRow,
),
Preset::Workstation => base(
KeepAlive::Duration { seconds: 300 },
Topology::Exclusive,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::Manual,
),
})
}
/// The persisted policy store: the loaded file value (or `None` when no file exists) behind its
/// JSON path. Mirrors [`crate::gpu::GpuPrefStore`] — private dir, temp-write + atomic rename,
/// in-memory rollback if the disk write fails.
pub struct DisplayPolicyStore {
path: PathBuf,
/// `Some` only when a valid `display-settings.json` was loaded / written — the "console has
/// configured this host" signal that gates whether Stage-0 call sites override their historical
/// env/default behavior.
cur: Mutex<Option<DisplayPolicy>>,
}
impl DisplayPolicyStore {
/// Load from `path`. A missing file ⇒ unconfigured (`None`); a corrupt file ⇒ unconfigured with a
/// warning (never fail host startup over a settings file).
pub fn load_from(path: PathBuf) -> Self {
let cur = match std::fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<DisplayPolicy>(&bytes) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!(path = %path.display(),
"display-settings.json unreadable — using built-in defaults: {e}");
None
}
},
Err(_) => None,
};
DisplayPolicyStore {
path,
cur: Mutex::new(cur),
}
}
/// The stored policy, or [`DisplayPolicy::default`] when unconfigured (for the mgmt GET).
pub fn get(&self) -> DisplayPolicy {
self.cur.lock().unwrap().clone().unwrap_or_default()
}
/// The console-configured policy, or `None` when no settings file exists. Stage-0 call sites use
/// this to decide whether to override their historical behavior (`None` ⇒ leave it untouched).
pub fn configured(&self) -> Option<DisplayPolicy> {
self.cur.lock().unwrap().clone()
}
/// The effective (preset-expanded) policy the console configured, or `None` when unconfigured.
pub fn configured_effective(&self) -> Option<EffectivePolicy> {
self.configured().map(|p| p.effective())
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing.
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
let policy = policy.sanitized();
if let Some(dir) = self.path.parent() {
crate::gamestream::create_private_dir(dir)?;
}
let tmp = self.path.with_extension("json.tmp");
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&policy)?)?;
std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(policy);
Ok(())
}
}
/// The process-wide display-policy store (config-dir file), loaded once on first access — the same
/// global-accessor shape as [`crate::gpu::prefs`], because display setup happens deep in the
/// capture/vdisplay path where no app state is threaded.
pub fn prefs() -> &'static DisplayPolicyStore {
static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new();
STORE.get_or_init(|| {
DisplayPolicyStore::load_from(crate::gamestream::config_dir().join("display-settings.json"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keep_alive_serializes_tagged_on_mode() {
assert_eq!(
serde_json::to_value(KeepAlive::Duration { seconds: 300 }).unwrap(),
serde_json::json!({ "mode": "duration", "seconds": 300 })
);
assert_eq!(
serde_json::to_value(KeepAlive::Off).unwrap(),
serde_json::json!({ "mode": "off" })
);
assert_eq!(
serde_json::to_value(KeepAlive::Forever).unwrap(),
serde_json::json!({ "mode": "forever" })
);
// Round-trips.
for k in [
KeepAlive::Off,
KeepAlive::Duration { seconds: 42 },
KeepAlive::Forever,
] {
let s = serde_json::to_string(&k).unwrap();
assert_eq!(serde_json::from_str::<KeepAlive>(&s).unwrap(), k);
}
}
#[test]
fn keep_alive_linger_resolution() {
assert_eq!(KeepAlive::Off.linger(), Linger::Immediate);
assert_eq!(
KeepAlive::Duration { seconds: 30 }.linger(),
Linger::For(Duration::from_secs(30))
);
assert_eq!(KeepAlive::Forever.linger(), Linger::Forever);
}
#[test]
fn default_policy_is_todays_behavior() {
let e = DisplayPolicy::default().effective();
assert_eq!(e.keep_alive, KeepAlive::Duration { seconds: 10 });
assert_eq!(e.topology, Topology::Auto);
assert_eq!(e.mode_conflict, ModeConflict::Separate);
assert_eq!(e.identity, Identity::PerClient);
assert_eq!(e.layout.mode, LayoutMode::AutoRow);
}
#[test]
fn custom_uses_explicit_fields_presets_override_them() {
// Custom: explicit fields flow through.
let p = DisplayPolicy {
preset: Preset::Custom,
keep_alive: KeepAlive::Off,
topology: Topology::Extend,
..DisplayPolicy::default()
};
assert_eq!(p.effective().keep_alive, KeepAlive::Off);
assert_eq!(p.effective().topology, Topology::Extend);
// A named preset ignores the explicit fields.
let p = DisplayPolicy {
preset: Preset::GamingRig,
keep_alive: KeepAlive::Off, // ignored
topology: Topology::Extend, // ignored
..DisplayPolicy::default()
};
let e = p.effective();
assert_eq!(e.keep_alive, KeepAlive::Forever);
assert_eq!(e.topology, Topology::Exclusive);
assert_eq!(e.mode_conflict, ModeConflict::Steal);
}
#[test]
fn workstation_preset_keeps_manual_layout_positions() {
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 2560, y: 0 });
let p = DisplayPolicy {
preset: Preset::Workstation,
layout: Layout {
mode: LayoutMode::AutoRow, // preset forces Manual regardless
positions,
},
..DisplayPolicy::default()
};
let e = p.effective();
assert_eq!(e.layout.mode, LayoutMode::Manual);
assert_eq!(
e.layout.positions.get("1"),
Some(&Position { x: 2560, y: 0 })
);
}
#[test]
fn every_preset_expands() {
for preset in [
Preset::Default,
Preset::GamingRig,
Preset::SharedDesktop,
Preset::Hotdesk,
Preset::Workstation,
] {
assert!(preset_fields(preset).is_some(), "{preset:?} must expand");
}
assert!(preset_fields(Preset::Custom).is_none());
}
#[test]
fn sanitize_clamps_max_displays_and_pins_version() {
let p = DisplayPolicy {
version: 99,
max_displays: 0,
..DisplayPolicy::default()
}
.sanitized();
assert_eq!(p.version, 1);
assert_eq!(p.max_displays, 1);
let p = DisplayPolicy {
max_displays: 999,
..DisplayPolicy::default()
}
.sanitized();
assert_eq!(p.max_displays, 16);
}
#[test]
fn 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);
}
}
@@ -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<u64>,
/// Live sessions holding the display.
pub sessions: u32,
/// Short client label (cert-fp prefix / peer), when the owner tracks it.
pub client: Option<String>,
/// 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<u32>,
/// 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<DisplayInfo>,
}
/// 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<dyn super::VirtualDisplay>,
mode: super::Mode,
quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Result<super::VirtualOutput> {
#[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<u64>) -> 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<dyn Send>,
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<u32>,
/// 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<Restore>,
/// 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<dyn FnOnce() + Send>;
/// 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<Restore>,
) -> Option<Restore> {
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<Vec<Entry>>,
gen: AtomicU64,
}
static REG: OnceLock<Reg> = 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<Entry>, now: Instant) -> (Vec<Entry>, Vec<Restore>) {
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<AtomicBool>,
) -> VirtualOutput {
VirtualOutput {
node_id,
remote_fd: None,
preferred_mode,
keepalive: Box::new(DisplayLease { gen, quit }),
}
}
pub(super) fn acquire(
vd: &mut Box<dyn VirtualDisplay>,
mode: Mode,
quit: Arc<AtomicBool>,
) -> Result<VirtualOutput> {
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<u32>,
state: &'static str,
expires_in_ms: Option<u64>,
sessions: u32,
}
pub(super) fn snapshot() -> Vec<DisplayInfo> {
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<Row> = {
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<crate::vdisplay::layout::Member> =
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<Row>,
layout_policy: &Layout,
topology: &str,
) -> Vec<DisplayInfo> {
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<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
keys.sort();
keys.dedup();
let mut out: Vec<DisplayInfo> = 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<usize> = 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<Member> = 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<u64>) -> 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<AtomicBool>,
}
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<Restore>) -> 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<AtomicBool>) -> 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<u32>) -> 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));
}
}
}
@@ -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<Entry>,
}
#[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::<Store>(&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);
}
}
@@ -34,7 +34,7 @@ use windows::Win32::System::Threading::{
use super::{Mode, VirtualOutput}; use super::{Mode, VirtualOutput};
use crate::win_display::{ use crate::win_display::{
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, 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 /// 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, Idle,
Active { mon: Monitor, refs: u32 }, Active { mon: Monitor, refs: u32 },
Lingering { mon: Monitor, until: Instant }, 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 /// 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 /// 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`). /// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>, idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
/// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the // The per-client stable monitor-id map is now the process-wide `super::identity::global()`
/// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across // (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
/// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`]. // resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
identity_map: Mutex<super::identity::MonitorIdentityMap>, // ConnectorIndex across reconnects and Windows reapplies its saved per-monitor DPI scaling.
} }
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new(); static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
@@ -188,7 +194,6 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
state: Mutex::new(MgrState::Idle), state: Mutex::new(MgrState::Idle),
setup_lock: Mutex::new(()), setup_lock: Mutex::new(()),
idd_session_stop: Mutex::new(None), 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 mut state = self.state.lock().unwrap();
let dev = self.ensure_device()?; let dev = self.ensure_device()?;
// IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the // IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client
// prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black // RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it
// screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a // hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. // 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 // ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
// path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session, // build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a
// NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every // concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn
// retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at // REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool
// 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD). // and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
if matches!(*state, MgrState::Lingering { .. }) { if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle) 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!( tracing::info!(
old_target = mon.target_id, 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 // 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 // `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)); 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) { let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
MgrState::Lingering { mut mon, .. } => { MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => {
tracing::info!( tracing::info!(
backend = self.driver.name(), backend = self.driver.name(),
"virtual monitor reused (reconnect within the linger window)" "virtual monitor reused (reconnect to a kept monitor)"
); );
if mon.mode != mode { if mon.mode != mode {
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live // SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
@@ -527,10 +540,14 @@ impl VirtualDisplayManager {
) -> Result<Monitor> { ) -> Result<Monitor> {
// Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved // 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 // per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver
// auto-allocates the lowest-free id (the original slot-based behavior). // auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy
let preferred_id = client_fp // picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior).
.map(|fp| self.identity_map.lock().unwrap().resolve(fp)) let preferred_id = super::identity::resolve_slot(
.unwrap_or(0); 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 // 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. // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed // `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
@@ -630,17 +647,36 @@ impl VirtualDisplayManager {
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id); 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. // ADD only advertises the mode; force it active so DXGI captures the requested size.
set_active_mode(n, mode); set_active_mode(n, mode);
// Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD // Apply the display-management topology (Stage 2). `Exclusive` (default) deactivates the
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other // other display(s) so the IDD is the SOLE composited primary — an EXTENDED (non-primary)
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no // IDD isn't DWM-composited on this box → Desktop Duplication born-losts. `Primary` keeps the
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1. // physical display(s) ACTIVE and makes the IDD primary (repositioned to origin). `Extend`
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() { // leaves it a plain extension. Both isolate + primary go through the atomic CCD path (no
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a // MODE_CHANGE storm). Opt out (extend) with PUNKTFUNK_NO_ISOLATE=1 / the console policy.
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed use crate::vdisplay::policy::Topology;
// memory crosses). It runs under the `state` lock, the sole mutator of the topology. match topology_action() {
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) }; // SAFETY (both arms): the CCD helper is `unsafe` for its topology FFI; it takes a
} else { // `Copy` `u32` by value and returns an owned `SavedConfig` (no borrowed memory crosses),
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended"); // 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 thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
} }
@@ -725,7 +761,9 @@ impl VirtualDisplayManager {
fn release(&self, gen: u64) { fn release(&self, gen: u64) {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
let stale = match &*state { 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, MgrState::Idle => true,
}; };
if stale { if stale {
@@ -736,6 +774,14 @@ impl VirtualDisplayManager {
mon, mon,
refs: refs - 1, 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, .. } => { MgrState::Active { mon, .. } => {
let ms = linger_ms(); let ms = linger_ms();
tracing::info!( tracing::info!(
@@ -890,10 +936,139 @@ fn resolve_render_pin() -> Option<LUID> {
crate::win_adapter::resolve_render_adapter_luid() crate::win_adapter::resolve_render_adapter_luid()
} }
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`). /// 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<u64>,
/// 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<ManagedInfo> {
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<ManagedInfo> {
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 { 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") std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(10_000) .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
}
}
+137 -29
View File
@@ -18,11 +18,13 @@ use windows::Win32::Devices::Display::{
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes, DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE, 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, DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION, 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, SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
}; };
use windows::Win32::Foundation::POINTL;
use windows::Win32::Graphics::Gdi::{ use windows::Win32::Graphics::Gdi::{
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW, ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
@@ -353,6 +355,48 @@ pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_M
/// doesn't export it, so define it here. /// doesn't export it, so define it here.
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001; const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
/// Query the current ACTIVE display config (paths + modes), truncated to the real counts. `None` on
/// API failure. Shared by [`isolate_displays_ccd`] (snapshot + per-attempt re-query) and
/// [`count_other_active`].
unsafe fn query_active_config() -> Option<SavedConfig> {
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<u32> {
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 + /// 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 /// 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 / /// 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 // 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). // (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<SavedConfig> { pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
// 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<SavedConfig> {
let mut np = 0u32; let mut np = 0u32;
let mut nm = 0u32; let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { 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<SavedCo
paths.truncate(np as usize); paths.truncate(np as usize);
modes.truncate(nm as usize); modes.truncate(nm as usize);
let saved = (paths.clone(), modes.clone()); let saved = (paths.clone(), modes.clone());
let mut others = 0u32;
for p in paths.iter_mut() { // The virtual output's source width, to lay the other displays out to its right.
if p.targetInfo.id == keep_target_id { let virt_width = paths.iter().find_map(|p| {
if p.targetInfo.id != keep_target_id {
return None;
}
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
let m = modes.get(idx)?;
(m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE)
.then(|| m.Anonymous.sourceMode.width as i32)
})?;
let others = paths.len().saturating_sub(1);
// Reposition each active path's SOURCE once: the virtual to (0,0) (= primary), the other
// displays PACKED left-to-right from the virtual's right edge — kept active, no overlap and no
// gap (vs. blindly shifting each by virt_width, which leaves a dead gap when EXTEND already
// placed them to the right). Dedup source-mode indices (a cloned group shares one).
let mut next_x = virt_width;
let mut done = std::collections::HashSet::new();
for p in paths.iter() {
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
if !done.insert(idx) {
continue; continue;
} }
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 { let Some(m) = modes.get_mut(idx) else {
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive continue;
others += 1; };
if m.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
continue;
}
if p.targetInfo.id == keep_target_id {
m.Anonymous.sourceMode.position = POINTL { x: 0, y: 0 };
} else {
let w = m.Anonymous.sourceMode.width as i32;
m.Anonymous.sourceMode.position = POINTL { x: next_x, y: 0 };
next_x += w;
} }
} }
if others == 0 {
// The virtual path shows active in the CCD database (from set_active_mode's legacy
// ChangeDisplaySettingsExW), but a legacy mode-set does NOT drive the IddCx adapter's
// EVT_IDD_CX_ADAPTER_COMMIT_MODES — and without COMMIT_MODES the OS never calls
// ASSIGN_SWAPCHAIN, so the driver never receives composed frames. Force an explicit CCD
// SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates.
// SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB
// already lists the path active.
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
| SDC_ALLOW_CHANGES
| SDC_SAVE_TO_DATABASE
| SDC_FORCE_MODE_ENUMERATION,
);
tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)");
return Some(saved);
}
let rc = SetDisplayConfig( let rc = SetDisplayConfig(
Some(paths.as_slice()), Some(paths.as_slice()),
Some(modes.as_slice()), Some(modes.as_slice()),
@@ -424,9 +532,9 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
| SDC_FORCE_MODE_ENUMERATION, | SDC_FORCE_MODE_ENUMERATION,
); );
if rc == 0 { if rc == 0 {
tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop"); tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
} else { } else {
tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))"); tracing::warn!("display primary (CCD): SetDisplayConfig failed rc={rc:#x} (virtual {keep_target_id} primary, physicals kept)");
} }
Some(saved) Some(saved)
} }
+13
View File
@@ -33,6 +33,10 @@ pub struct Summary {
pub native_paired_clients: u32, pub native_paired_clients: u32,
pub pin_pending: bool, pub pin_pending: bool,
pub pending_approvals: u32, pub pending_approvals: u32,
/// Virtual displays kept with no live session (lingering/pinned). `#[serde(default)]` so an older
/// host that doesn't send it deserializes as 0.
#[serde(default)]
pub kept_displays: u32,
} }
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
@@ -71,6 +75,14 @@ impl TrayStatus {
s.version, sess.width, sess.height, sess.fps s.version, sess.width, sess.height, sess.fps
), ),
(_, true) => format!("punktfunk host {} — streaming", s.version), (_, true) => 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), _ => format!("punktfunk host {} — idle", s.version),
}, },
} }
@@ -432,6 +444,7 @@ mod tests {
native_paired_clients: 2, native_paired_clients: 2,
pin_pending: false, pin_pending: false,
pending_approvals: 0, pending_approvals: 0,
kept_displays: 0,
} }
} }
+905
View File
@@ -0,0 +1,905 @@
# Virtual-display management & lifecycle policy — design
> **Status (2026-07-05):** **Stages 05 (§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 (`<config>/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<OwnedFd>, // Mutter portal daemon (dup'd per capture attach)
pub preferred_mode: Option<(u32, u32, u32)>,
#[cfg(windows)] pub win_capture: Option<WinCaptureTarget>,
}
// registry.acquire(...) -> (DisplayLease, CaptureSource)
```
The `keepalive: Box<dyn Send>` 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 (`<config>/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": { /* "<slot>": {"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": {"<fp>": {…}}` 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 <phys> 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 <name>"); 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 `<config>/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-<slot>` → output `Virtual-punktfunk-<slot>`. 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 <n> 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 <n> 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": { "<slot>": {"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 04: 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 <client>"`, 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-<id>` 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-<id>` (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-<id>` — 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 13 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.<n>.position.<x>,<y>`): 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.
+8 -2
View File
@@ -62,9 +62,15 @@ picture.
## Compositor-specific (Linux) ## 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 | | 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_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). | | `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_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_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_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. | | `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
+1
View File
@@ -24,6 +24,7 @@
"pairing", "pairing",
"---Configuration---", "---Configuration---",
"configuration", "configuration",
"virtual-displays",
"host-cli", "host-cli",
"---Troubleshooting---", "---Troubleshooting---",
"troubleshooting", "troubleshooting",
+46 -5
View File
@@ -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 - 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 routed subnets or most VPNs-without-multicast. As a fallback, add the host by **IP address** in your
client. client.
- A firewall on the host can block it. The native protocol's control plane uses UDP port **9777**. The - A firewall on the host can block it. The native protocol's **control plane** is a fixed UDP port,
per-session **data plane** uses an *ephemeral* UDP port negotiated at connect time (currently **9777** — open this one. The per-session **data plane** rides a *separate, random* UDP port and
random) — for a strict firewall, open a UDP range or move the data port. GameStream/Moonlight uses usually needs **no** firewall rule (see [Video is slow to start, or fails across
TCP **47984/47989/48010** + UDP **4799848010** + ENet UDP **47999**. Allow them on the host's subnets](#video-is-slow-to-start-or-fails-across-subnets) for why, and the one case where opening it
firewall. helps). GameStream/Moonlight (only with `--gamestream`) uses TCP **47984/47989/48010** + UDP
**4799848010** (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 ## `nvidia-smi` says it can't communicate with the driver
+151
View File
@@ -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).
+9
View File
@@ -274,6 +274,15 @@
#define VIDEO_CAP_HOST_TIMING 8 #define VIDEO_CAP_HOST_TIMING 8
#endif #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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // [`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 // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
+15 -5
View File
@@ -176,8 +176,16 @@ Prefer explicit rules (or a firewall the shipped profiles don't cover)? Open the
The **native `punktfunk/1`** plane: The **native `punktfunk/1`** plane:
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change). - **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 - **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
as outbound UDP is allowed (the host streams back out through the client-opened path). 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 <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 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: `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 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 47998,47999,48000/udp # GameStream video/control/audio
sudo ufw allow 5353/udp # mDNS discovery 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): 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 udp dport 9777 accept # punktfunk/1 control plane
tcp dport { 47984, 47989, 48010 } accept tcp dport { 47984, 47989, 48010 } accept
udp dport { 47998-48000, 5353 } accept # GameStream video/control/audio + mDNS 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 # The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
# accepts ct state established,related (as this one should) passes the return with nothing extra. # fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.
``` ```
## Files ## Files
+8 -3
View File
@@ -374,9 +374,14 @@ sudo firewall-cmd --reload
default unit): default unit):
- **QUIC control plane: UDP 9777** (default `--port`; change with `--port N`). - **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 - **Data plane: a separate UDP port** — by default *random* (`0.0.0.0:0`), so there is **no fixed
port it got, so there is **no fixed data port to open**. For a restrictive firewall you'd need to port to open**. Video flows host → client, but the client sends the first packet (a hole-punch): if
allow the ephemeral UDP range; the repo does not pin one. 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 <PORT>` (or `PUNKTFUNK_DATA_PORT`) and open exactly that one port with
`firewall-cmd --add-port=<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 ```sh
# Only if you run `punktfunk1-host`: # Only if you run `punktfunk1-host`:
+15 -4
View File
@@ -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: Prefer explicit rules? Open the ports directly. The **native `punktfunk/1`** plane:
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change). - **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 - **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
as outbound UDP is allowed (the host streams back out through the client-opened path). 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 <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 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: `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 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 47998,47999,48000/udp # GameStream video/control/audio
sudo ufw allow 5353/udp # mDNS discovery 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): 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 udp dport 9777 accept # punktfunk/1 control plane
tcp dport { 47984, 47989, 48010 } accept tcp dport { 47984, 47989, 48010 } accept
udp dport { 47998-48010, 5353 } 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 <PORT>` to open exactly one instead.
``` ```
## Updates ## Updates
+9
View File
@@ -48,6 +48,15 @@ PUNKTFUNK_ZEROCOPY=1
#PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput #PUNKTFUNK_INPUT_BACKEND=libei # wlr | libei | gamescope | uinput
#PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent #PUNKTFUNK_FEC_PCT=20 # video FEC overhead percent
#PUNKTFUNK_PERF=1 # per-stage timing logs #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 # 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 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; # the session stays 4:2:0). HEVC-only; independent of 10-bit. NVENC (NVIDIA) is the validated path;
+56
View File
@@ -4,6 +4,7 @@
"app_tagline": "Verwaltungskonsole", "app_tagline": "Verwaltungskonsole",
"nav_dashboard": "Übersicht", "nav_dashboard": "Übersicht",
"nav_host": "Host", "nav_host": "Host",
"nav_displays": "Virtuelle Anzeigen",
"nav_clients": "Gekoppelte Geräte", "nav_clients": "Gekoppelte Geräte",
"nav_pairing": "Kopplung", "nav_pairing": "Kopplung",
"nav_library": "Bibliothek", "nav_library": "Bibliothek",
@@ -47,6 +48,61 @@
"gpu_none": "Keine GPUs erkannt.", "gpu_none": "Keine GPUs erkannt.",
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.", "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.", "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_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.", "clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name", "clients_name": "Name",
+56
View File
@@ -4,6 +4,7 @@
"app_tagline": "management console", "app_tagline": "management console",
"nav_dashboard": "Dashboard", "nav_dashboard": "Dashboard",
"nav_host": "Host", "nav_host": "Host",
"nav_displays": "Virtual displays",
"nav_clients": "Paired clients", "nav_clients": "Paired clients",
"nav_pairing": "Pairing", "nav_pairing": "Pairing",
"nav_library": "Library", "nav_library": "Library",
@@ -47,6 +48,61 @@
"gpu_none": "No GPUs detected.", "gpu_none": "No GPUs detected.",
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.", "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.", "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_title": "Paired clients",
"clients_empty": "No paired clients yet.", "clients_empty": "No paired clients yet.",
"clients_name": "Name", "clients_name": "Name",
+2
View File
@@ -4,6 +4,7 @@ import {
GaugeCircle, GaugeCircle,
KeyRound, KeyRound,
LibraryBig, LibraryBig,
MonitorPlay,
ScrollText, ScrollText,
Server, Server,
Settings, Settings,
@@ -21,6 +22,7 @@ const MLink = motion(Link);
const NAV = [ const NAV = [
{ to: "/", icon: Activity, label: () => m.nav_dashboard() }, { to: "/", icon: Activity, label: () => m.nav_dashboard() },
{ to: "/host", icon: Server, label: () => m.nav_host() }, { 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: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() }, { to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionDisplays } from "@/sections/Displays";
export const Route = createFileRoute("/displays")({ component: SectionDisplays });
+629
View File
@@ -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<DisplayPolicy | null>(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 (
<div className="flex flex-col gap-card">
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{q.data && draft && (
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={q.data.presets}
apply={apply}
busy={save.isPending}
error={apiErrorMessage(save.error)}
/>
)}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.display_live()}</CardTitle>
</CardHeader>
<CardContent>
<LiveDisplays />
</CardContent>
</Card>
</div>
);
};
/** 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 (
<div className="space-y-6">
{/* One-click presets — a 2-up grid so each has room to breathe */}
<div className="space-y-3">
<Label className="text-base font-semibold">{m.display_preset()}</Label>
<div className="grid gap-3 sm:grid-cols-2">
{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 (
<Card
key={id}
interactive
role="button"
tabIndex={disabled ? -1 : 0}
aria-pressed={selected}
aria-disabled={disabled || undefined}
onClick={pick}
onKeyDown={(e) => {
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",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-base font-semibold">
{(PRESET_LABEL[id] ?? (() => id))()}
{soon && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
{m.display_preset_soon()}
</span>
)}
</span>
{selected && (
<Badge variant="success">{m.display_preset_current()}</Badge>
)}
</div>
{summary && (
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
)}
{fields && (
<div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
</div>
)}
</Card>
);
})}
</div>
</div>
{/* Custom: every option by hand */}
{isCustom && (
<div className="space-y-6 rounded-lg border p-5">
<Field label={m.display_keep_alive()} help={m.display_keep_alive_help()}>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={ka.mode === "off" ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
>
{m.display_keep_alive_off()}
</Button>
<Button
size="sm"
variant={ka.mode === "duration" ? "default" : "outline"}
disabled={busy}
onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: keepSecs } })
}
>
{m.display_keep_alive_keep()}
</Button>
{ka.mode === "duration" && (
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
className="w-24"
value={ka.seconds}
disabled={busy}
onChange={(e) => {
const n = Math.max(0, Number(e.target.value) || 0);
setKeepSecs(n);
setDraft({ ...draft, keep_alive: { mode: "duration", seconds: n } });
}}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
)}
</div>
</Field>
<Choice
label={m.display_topology()}
help={m.display_topology_help()}
value={customFields.topology}
options={["auto", "extend", "primary", "exclusive"]}
labels={TOPOLOGY_LABEL}
disabled={busy}
onPick={(v) => setDraft({ ...draft, topology: v as Topology })}
/>
<Choice
label={m.display_conflict()}
help={m.display_conflict_help()}
value={customFields.mode_conflict}
options={["separate", "steal", "join", "reject"]}
labels={CONFLICT_LABEL}
disabled={busy}
onPick={(v) => setDraft({ ...draft, mode_conflict: v as ModeConflict })}
/>
<Choice
label={m.display_identity()}
help={m.display_identity_help()}
value={customFields.identity}
options={["shared", "per-client", "per-client-mode"]}
labels={IDENTITY_LABEL}
disabled={busy}
onPick={(v) => setDraft({ ...draft, identity: v as Identity })}
/>
<Choice
label={m.display_layout_mode()}
help={m.display_layout_help()}
value={customFields.layout.mode ?? "auto-row"}
options={["auto-row", "manual"]}
labels={LAYOUT_LABEL}
disabled={busy}
onPick={(v) =>
setDraft({
...draft,
layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} },
})
}
/>
<Field label={m.display_max()}>
<Input
type="number"
min={1}
max={16}
className="w-24"
value={draft.max_displays ?? 4}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
</Field>
<div className="border-t pt-4">
<Button onClick={() => apply(draft)} disabled={busy}>
{m.display_save()}
</Button>
</div>
</div>
)}
{/* What's in force right now */}
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, effective.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, effective.mode_conflict)}</Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge>
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
</div>
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>}
</div>
);
};
/** 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,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
/** 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, () => string>;
disabled: boolean;
onPick: (v: string) => void;
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
<Field label={label} help={help}>
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o}
size="sm"
variant={value === o ? "default" : "outline"}
disabled={disabled}
onClick={() => onPick(o)}
>
{(labels[o] ?? (() => o))()}
</Button>
))}
</div>
</Field>
);
/**
* 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 (
<div className="space-y-3">
{kept.length > 0 && (
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={release.isPending}
onClick={() => doRelease()}
>
{m.display_release_all()}
</Button>
</div>
)}
{displays.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
) : (
<ul className="divide-y rounded-md border">
{displays.map((d) => (
<DisplayRow
key={d.slot}
d={d}
busy={release.isPending}
onRelease={() => doRelease(d.slot)}
/>
))}
</ul>
)}
<DisplayArrangement displays={displays} />
</div>
);
};
/**
* 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<Record<string, { x: number; y: number }> | null>(null);
useEffect(() => {
if (pos === null && arrangeable.length > 0) {
const seed: Record<string, { x: number; y: number }> = {};
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 (
<div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium">{m.display_arrange()}</h4>
<p className="text-xs text-muted-foreground">{m.display_arrange_help()}</p>
<div className="space-y-2">
{arrangeable.map((d) => {
const slot = d.identity_slot as number;
const p = cur[String(slot)] ?? { x: d.x, y: d.y };
return (
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-44 truncate">
{d.mode} <code className="text-xs text-muted-foreground">#{slot}</code>
</span>
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
X
</Label>
<Input
id={`disp-x-${slot}`}
type="number"
className="w-24"
value={p.x}
disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))}
/>
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
Y
</Label>
<Input
id={`disp-y-${slot}`}
type="number"
className="w-24"
value={p.y}
disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))}
/>
</div>
);
})}
</div>
{saveLayout.error && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{apiErrorMessage(saveLayout.error)}
</p>
)}
<Button size="sm" onClick={onSave} disabled={saveLayout.isPending}>
{m.display_arrange_save()}
</Button>
</div>
);
};
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 (
<li className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{d.mode}</span>
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
{active && d.sessions > 0 && (
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge>
)}
</div>
<code className="text-xs text-muted-foreground">
{d.backend}
{d.expires_in_ms != null
? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}`
: ""}
</code>
</div>
{!active && (
<Button size="sm" variant="outline" disabled={busy} onClick={onRelease}>
{m.display_release_btn()}
</Button>
)}
</li>
);
};
/** 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<string> = new Set<string>();
const PRESET_LABEL: Record<string, () => string> = {
custom: m.display_preset_custom,
default: m.display_preset_default,
"gaming-rig": m.display_preset_gaming_rig,
"shared-desktop": m.display_preset_shared_desktop,
hotdesk: m.display_preset_hotdesk,
workstation: m.display_preset_workstation,
};
const TOPOLOGY_LABEL: Record<string, () => 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, () => 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, () => 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, () => 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, () => 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 "∞";
}
};
+22
View File
@@ -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 (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection />
</div>
</Section>
);
};
+1 -3
View File
@@ -9,7 +9,5 @@ export const SectionHost: FC = () => {
const host = useGetHostInfo(); const host = useGetHostInfo();
const compositors = useListCompositors(); const compositors = useListCompositors();
return ( return <HostView host={host} compositors={compositors} gpu={<GpuSection />} />;
<HostView host={host} compositors={compositors} gpu={<GpuSection />} />
);
}; };