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
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:
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +253,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
|
|||||||
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
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,12 +286,18 @@ 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,
|
||||||
})
|
},
|
||||||
|
// 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")?;
|
.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
|
||||||
@@ -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 + Reed–Solomon
|
||||||
|
/// FEC parity shards) via the stateful [`VideoPacketizer`], then hands the batch to the paced sender.
|
||||||
|
/// It sits between encode and send so the FEC never blocks the encode loop. Backpressure: the hand-off
|
||||||
|
/// to the sender BLOCKS, so if the paced sender falls behind, the packetizer stalls and the
|
||||||
|
/// encode→packetizer queue fills — the encode loop then drops the newest frame (see the loop) rather
|
||||||
|
/// than stalling. Tallies goodput (bytes handed to the wire) into `goodput` for the encode loop's stats
|
||||||
|
/// window. Exits when either neighbor's channel closes (session teardown / client gone).
|
||||||
|
fn spawn_packetizer(
|
||||||
|
rx: std::sync::mpsc::Receiver<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) {
|
||||||
|
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();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
// punch-timeout to pay. (Direct trusts the reported port: it can't cross a client-side
|
||||||
|
// NAT that remaps it.)
|
||||||
|
let bound = if direct {
|
||||||
|
UdpTransport::from_socket(data_sock, &client_udp.to_string()).map(|t| (t, false))
|
||||||
|
} else {
|
||||||
|
UdpTransport::from_socket_punch(
|
||||||
|
data_sock,
|
||||||
&client_udp.to_string(),
|
&client_udp.to_string(),
|
||||||
std::time::Duration::from_millis(2500),
|
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));
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// bootstrap on its own; the belt-and-suspenders disable below covers the rest.
|
||||||
|
if !a_managed_output_is_primary() {
|
||||||
if !kscreen(&[format!("output.{ours}.primary")]) {
|
if !kscreen(&[format!("output.{ours}.primary")]) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"KWin: could not set the virtual output primary; client may see only the wallpaper"
|
"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,9 +540,13 @@ 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(
|
||||||
|
client_fp,
|
||||||
|
(mode.width, mode.height),
|
||||||
|
crate::vdisplay::policy::Identity::PerClient,
|
||||||
|
)
|
||||||
.unwrap_or(0);
|
.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.
|
||||||
@@ -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() {
|
||||||
|
// SAFETY (both arms): the CCD helper is `unsafe` for its topology FFI; it takes a
|
||||||
|
// `Copy` `u32` by value and returns an owned `SavedConfig` (no borrowed memory crosses),
|
||||||
|
// and runs under the `state` lock, the sole mutator of the topology.
|
||||||
|
Topology::Exclusive => {
|
||||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||||
} else {
|
}
|
||||||
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,905 @@
|
|||||||
|
# Virtual-display management & lifecycle policy — design
|
||||||
|
|
||||||
|
> **Status (2026-07-05):** **Stages 0–5 (§6A) DONE + on-glass validated; keep-alive reconnect
|
||||||
|
> hardened** (branch `display-mgmt-stage0`, not yet merged). Stage 5 §6A: display **groups**
|
||||||
|
> (`registry::group_key` — one per desktop backend, each gamescope spawn its own), group-aware
|
||||||
|
> `exclusive`/`primary` (KWin name-filter + first-slot-wins; Mutter `set_first_in_group`), **per-group
|
||||||
|
> topology restore** (KWin restore floats through the group, runs on the last member's teardown), the
|
||||||
|
> **layout engine** (`vdisplay/layout.rs`, auto-row + manual) + registry-driven `apply_position`, the
|
||||||
|
> `PUT /display/layout` endpoint with group/position/index in `/display/state`, and the **web console
|
||||||
|
> arrangement table** — **live-validated on KWin `.116` + Mutter `.21`** (group model, positions,
|
||||||
|
> identity keying, group-aware exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors). The
|
||||||
|
> Stage-3 **KDE scaling round-trip is now proven live** (set 150 %/125 % → disconnect → reconnect →
|
||||||
|
> reapplied, seen in `kwinoutputconfig.json`). **Keep-alive reconnect hardening (`b53710d`, on-glass
|
||||||
|
> validated with the probe):** a same-client reconnect **preempts its own zombie**
|
||||||
|
> (`admission::preempt_same_identity` — fixes "reconnect within the idle-detection window lands on a
|
||||||
|
> fresh SECOND display while the old one keeps streaming"), a **deliberate quit skips the linger**
|
||||||
|
> (client closes with `QUIT_CLOSE_CODE` 0x51 → `registry::release(force_immediate)`; §5.1), and the QUIC
|
||||||
|
> control-connection idle timeout (the disconnect-detection latency) is **host-tunable**
|
||||||
|
> (`PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`, default 8 s). **Remaining Stage 5 = hardware-gated
|
||||||
|
> residuals only**: the per-group physical-restore EFFECT (needs a monitor-attached Linux box — the
|
||||||
|
> headless validation boxes report `also_disabled=[]`, so nothing is disabled to restore), wlroots
|
||||||
|
> `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert. See the **Status —
|
||||||
|
> handoff** block under §11 for the per-stage state and key decisions (notably the Windows `reject`
|
||||||
|
> default).
|
||||||
|
> This doc designs a **policy layer on top of the
|
||||||
|
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
|
||||||
|
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second
|
||||||
|
> client wants a different mode), stable display identity (so desktop environments remember
|
||||||
|
> per-client settings like scaling), and **multi-monitor** (several virtual displays forming one
|
||||||
|
> desktop, fed by one client or by several). The `VirtualDisplay` trait and the per-backend
|
||||||
|
> `create()` mechanics stay as they are; this layer decides *when* to create, *how many*, *how
|
||||||
|
> long* to keep, *what else* to do to the topology, and *under which identity*.
|
||||||
|
|
||||||
|
Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md`
|
||||||
|
(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent,
|
||||||
|
not required).
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Today the virtual-display behavior is hardcoded per platform and per backend:
|
||||||
|
|
||||||
|
- A session's virtual output is created at connect and torn down (RAII) at session end — a
|
||||||
|
disconnect destroys the display, reshuffles the desktop, and (on gamescope bare-spawn) **kills
|
||||||
|
the running game**.
|
||||||
|
- "Make the streamed output the sole desktop" is an env knob on Linux
|
||||||
|
(`PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY`, default-on for the
|
||||||
|
auto-detected desktop path) and default-on on Windows (`PUNKTFUNK_NO_ISOLATE` to opt out) —
|
||||||
|
and on Linux "primary" and "disable the other outputs" are conflated into one switch.
|
||||||
|
- What happens when a second client connects is an emergent property of the platform: Linux
|
||||||
|
creates a second output (multi-view), Windows **reconfigures the shared monitor under the
|
||||||
|
live session** (join-path `reconfigure` in `vdisplay/windows/manager.rs::acquire`), GameStream
|
||||||
|
preempts.
|
||||||
|
- Only Windows gives a client a stable monitor identity (`vdisplay/windows/identity.rs`), so only
|
||||||
|
Windows reapplies per-client display config (DPI scaling) across reconnects. On KDE every
|
||||||
|
session's output is `Virtual-punktfunk` at whatever mode — scaling has to be re-set per connect
|
||||||
|
and is shared across every client.
|
||||||
|
- One session = exactly one display. A client with two physical monitors can only stream one;
|
||||||
|
a tablet can't join an existing streamed desktop *as a second monitor* on purpose (the Linux
|
||||||
|
multi-view behavior half-does it by accident, with no layout control).
|
||||||
|
|
||||||
|
Goal: **one shared, documented configuration surface** — a small set of orthogonal options with
|
||||||
|
safe defaults and selectable presets, stored host-side, editable from the web console, applied
|
||||||
|
uniformly across the punktfunk/1 and GameStream paths and across all five backends (KWin,
|
||||||
|
gamescope, Mutter, wlroots, Windows pf-vdisplay), each backend implementing what it can and
|
||||||
|
**honestly declining** what it can't (the same honest-downgrade convention as 4:4:4/10-bit).
|
||||||
|
|
||||||
|
## 2. What exists today (inventory)
|
||||||
|
|
||||||
|
The asymmetry worth internalizing: **Windows already has most of the machinery; Linux has none.**
|
||||||
|
|
||||||
|
| Mechanism | Windows (pf-vdisplay) | Linux (kwin/mutter/wlroots) | gamescope |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Lifecycle owner | `VirtualDisplayManager` singleton — `Idle / Active{refs} / Lingering{until}` state machine, gen-stamped `MonitorLease` | none — session owns `VirtualOutput.keepalive`, capturer drop = teardown | managed path: debounced TV-session restore (`RESTORE_DEBOUNCE` 5 s) + warm-session reuse; spawn path: child dies with the session |
|
||||||
|
| Keep-alive after disconnect | linger, default 10 s (`PUNKTFUNK_MONITOR_LINGER_MS`) | none | managed: 5 s debounce (hardcoded) |
|
||||||
|
| Reuse on reconnect | join Active (refcount++) / adopt Lingering (with a dead-swapchain preempt for IDD) | none (always create fresh) | managed: reuses the warm session |
|
||||||
|
| Primary / exclusive | `isolate_displays_ccd` (exclusive), default on, restore on teardown | `apply_virtual_primary` = primary **and** disable others, env-gated, restore on drop; Mutter `make_virtual_primary` = sole monitor (APPLY_TEMPORARY) | n/a (own nested session) |
|
||||||
|
| Mode conflict | join-path silently reconfigures the shared monitor (last-wins) | each session gets its own output (multi-view) | managed: one session; spawn: one gamescope per client |
|
||||||
|
| Stable identity | `identity.rs` — cert-fp → id 1..=15 (EDID serial + ConnectorIndex), LRU, persisted `pf-vdisplay-identity.json` | none — KWin output always named `punktfunk`, sway `HEADLESS-N`, Mutter auto-serial | n/a |
|
||||||
|
| Multi-monitor | manager is single-monitor (driver supports 16 connectors) | N outputs happen to coexist (multi-view), no layout/group semantics | single-output nested session |
|
||||||
|
|
||||||
|
Design consequence: the plan is **not** "build a manager" — it's (a) extract the state machine
|
||||||
|
Windows already proved into a platform-neutral, unit-testable core, (b) give Linux the ownership
|
||||||
|
split it's missing (manager owns the keepalive, session holds a lease), (c) put a typed policy
|
||||||
|
in front of both, (d) extend identity to Linux where the compositor allows it, and (e) grow the
|
||||||
|
slot model into display **groups** so multi-monitor is an arrangement of slots, not a new system.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
Three new pieces, layered strictly **above** the `VirtualDisplay` trait (no backend rewrite):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────┐
|
||||||
|
mgmt API / console │ DisplayPolicy (vdisplay/policy.rs) │ pure config: schema,
|
||||||
|
host.env compat ───▶│ presets · layout · validation · persist │ presets, env-compat
|
||||||
|
└───────────────┬────────────────────────────┘
|
||||||
|
│ read per acquire/release (live-reload)
|
||||||
|
┌───────────────▼────────────────────────────┐
|
||||||
|
punktfunk/1 session │ DisplayRegistry (vdisplay/registry.rs) │ host-lifetime singleton:
|
||||||
|
GameStream session ─▶ acquire(identity, mode) → DisplayLease │ owns ManagedDisplay slots
|
||||||
|
mgmt /display/state │ release(lease) · linger timer · groups │ grouped per desktop,
|
||||||
|
└───────┬────────────────────────┬───────────┘ drives the pure Lifecycle
|
||||||
|
│ create()/drop keepalive │ reconfigure/topology/layout ops
|
||||||
|
┌────────────▼──────────┐ ┌──────────▼───────────────┐
|
||||||
|
│ Linux backends │ │ Windows │
|
||||||
|
│ kwin · gamescope · │ │ VirtualDisplayManager │
|
||||||
|
│ mutter · wlroots │ │ (existing; delegates its │
|
||||||
|
│ (unchanged trait) │ │ state decisions upward) │
|
||||||
|
└───────────────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`vdisplay/policy.rs`** — the typed config (`DisplayPolicy`), preset expansion, JSON
|
||||||
|
persistence (`<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 0–4: DONE + on-glass validated.** 0 (policy surface + `/display/settings` + console card),
|
||||||
|
1 (pure `lifecycle.rs` + `registry.rs` Linux keep-alive pool + ownership split via `DisplayLease` +
|
||||||
|
`/display/state`/`/display/release`), 2 (topology decoupling — distinct `extend`/`primary`/`exclusive`
|
||||||
|
via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot
|
||||||
|
output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`),
|
||||||
|
4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies).
|
||||||
|
- **Stage 5 (§6A): DONE + on-glass validated (KWin `.116` + Mutter `.21`).** All §6A group semantics
|
||||||
|
landed + unit-tested, then live-validated (group model, positions, identity keying, group-aware
|
||||||
|
exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors; the dev VM itself is GPU-less):
|
||||||
|
**display groups** (`registry::group_key` — one
|
||||||
|
per desktop backend, each gamescope spawn its own group), **group-aware exclusive/primary** (KWin
|
||||||
|
`MANAGED_PREFIX` + first-slot-wins; Mutter `set_first_in_group` → a non-first session extends rather than
|
||||||
|
re-clobbering), **per-group topology restore** (KWin hands its restore to the registry via
|
||||||
|
`take_topology_restore`; `Entry::topology_restore` + `hand_off_restore` float it to a surviving sibling
|
||||||
|
and run it only when the group empties, before the last output drops — all 3 teardown paths), the pure
|
||||||
|
**layout engine** (`vdisplay/layout.rs::arrange`, auto-row + manual) + **registry-driven `apply_position`**
|
||||||
|
(`position_for_new` over the whole group; skips the origin so the single-display path is unchanged), the
|
||||||
|
`PUT /api/v1/display/layout` endpoint (`EffectivePolicy::with_manual_layout`), and `/display/state` now
|
||||||
|
carrying `group`/`display_index`/`position`/`identity_slot`/`topology`. The registry keys the arrangement
|
||||||
|
on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). The **web arrangement table**
|
||||||
|
(`DisplayCard.tsx` `DisplayArrangement`, en+de) is also done, moved to its own **Virtual displays** nav
|
||||||
|
section with a full one-click-preset config surface. **Remaining = hardware-gated residuals only:** the
|
||||||
|
per-group physical-restore EFFECT (needs a monitor-attached box — the headless boxes report
|
||||||
|
`also_disabled=[]`), wlroots `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert —
|
||||||
|
see the Stage 5 entry below. Plus the **keep-alive reconnect hardening** (`b53710d`, on-glass validated):
|
||||||
|
same-client zombie preempt + deliberate-quit skip-linger + tunable idle timeout (§5.1) — what made
|
||||||
|
"reconnect resumes" actually hold under a fast reconnect.
|
||||||
|
|
||||||
|
**Decisions / deltas from this plan as written — read before continuing:**
|
||||||
|
- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two
|
||||||
|
concurrent Windows sessions both drive one pf-vdisplay monitor's **single-capturer** IDD-push channel
|
||||||
|
(newest-delivery-wins) → the 2nd freezes + can WEDGE the 1st (observed live: it wedged `.173`, needed a
|
||||||
|
reboot — surfaced as Moonlight "no video"). True multi-session Windows capture is §6.6/Stage 7. So on
|
||||||
|
Windows `separate` (incl. the unconfigured default) resolves to `reject` — a 2nd client gets a clean
|
||||||
|
503, the live session is protected; `join`/`steal` are explicit opt-ins. Centralised in
|
||||||
|
`admission::effective_conflict()`, shared by the native handshake + GameStream `h_launch`.
|
||||||
|
- **Reject IS typed:** punktfunk/1 closes the QUIC connection with app code `0x42` + the reason
|
||||||
|
`"host busy: streaming WxH@Hz to <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 1–3 infrastructure, no protocol change.
|
||||||
|
**Done:**
|
||||||
|
- KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the
|
||||||
|
`Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary,
|
||||||
|
unit-tested.
|
||||||
|
- **Layout engine** (`vdisplay/layout.rs::arrange`): pure auto-row (left-to-right in acquire order,
|
||||||
|
top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members),
|
||||||
|
unit-tested. `manual_position` helper for a single backend-local apply.
|
||||||
|
- **Registry group model** (Linux): group = backend (one desktop per compositor session); the
|
||||||
|
`/display/state` snapshot groups entries, orders by acquire (gen), and computes each member's
|
||||||
|
`position` via the engine. `DisplayInfo` now carries `group` / `display_index` / `position` /
|
||||||
|
`identity_slot` / `topology`. The backend reports its resolved slot via the new
|
||||||
|
`VirtualDisplay::last_identity_slot` (KWin only), so the arrangement + state key on per-client identity.
|
||||||
|
- **`PUT /api/v1/display/layout`**: persists the console's manual arrangement (positions keyed by
|
||||||
|
identity slot) via the pure `EffectivePolicy::with_manual_layout` transform (locks the current
|
||||||
|
effective behavior into explicit `Custom` fields + sets a manual layout — arranging is orthogonal to
|
||||||
|
the other axes). OpenAPI regenerated.
|
||||||
|
- **Registry-driven position apply** (`VirtualDisplay::apply_position(x, y)`, default no-op; KWin
|
||||||
|
implements it via `kscreen-doctor output.<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.
|
||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"pairing",
|
"pairing",
|
||||||
"---Configuration---",
|
"---Configuration---",
|
||||||
"configuration",
|
"configuration",
|
||||||
|
"virtual-displays",
|
||||||
"host-cli",
|
"host-cli",
|
||||||
"---Troubleshooting---",
|
"---Troubleshooting---",
|
||||||
"troubleshooting",
|
"troubleshooting",
|
||||||
|
|||||||
@@ -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 **47998–48010** + 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
|
||||||
|
**47998–48010** (video/FEC 47998, ENet control 47999, audio 48000) + mDNS UDP **5353**. Allow those
|
||||||
|
on the host's firewall.
|
||||||
|
|
||||||
|
## Video is slow to start, or fails across subnets
|
||||||
|
|
||||||
|
The native **data plane** (the raw UDP that carries video, separate from the 9777 control plane) uses
|
||||||
|
a **random, per-session UDP port** — the host binds `0.0.0.0:0`, then tells the client which port it
|
||||||
|
got during the connect handshake. There is no fixed data port.
|
||||||
|
|
||||||
|
Video flows host → client, but the **client sends the first packet**: a small *hole-punch* datagram to
|
||||||
|
that port. This is deliberate. It lets the host learn the client's real (possibly NAT-translated)
|
||||||
|
source address and stream back to it, so a session can cross a NAT or a stateful inter-VLAN firewall
|
||||||
|
**without** a forwarded data port. What it means for a host firewall:
|
||||||
|
|
||||||
|
- **Same LAN, no host firewall (or the port allowed):** the punch arrives immediately and video starts
|
||||||
|
at once. Nothing to configure.
|
||||||
|
- **Same LAN, host firewall that denies inbound** (ufw/nftables/firewalld default): the punch is
|
||||||
|
dropped, so the host waits **~2.5 s**, then falls back to the address the client reported and streams
|
||||||
|
anyway — a stateful firewall admits the return traffic because the host sent first. **Net effect: it
|
||||||
|
works, but each session takes ~2.5 s longer to start.** That slow start is the symptom of a
|
||||||
|
data-plane rule you're missing.
|
||||||
|
- **Across subnets / NAT:** the same punch-then-fallback applies, as long as the host's outbound video
|
||||||
|
can reach the client (the path's stateful firewall then admits the return). If the host itself is
|
||||||
|
behind NAT reached only via a forwarded control port, the data path may not establish — this is the
|
||||||
|
case a fixed, forwardable data port would solve.
|
||||||
|
|
||||||
|
To remove the ~2.5 s fallback delay, **pin the data port** with `--data-port` (or the
|
||||||
|
`PUNKTFUNK_DATA_PORT` env in `host.env`) and open exactly that one port. The host then binds that
|
||||||
|
fixed port, skips the punch-wait, and streams straight to the client — no timeout to pay:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
punktfunk-host serve --data-port 9778 # or PUNKTFUNK_DATA_PORT=9778 in host.env
|
||||||
|
sudo ufw allow 9778/udp # open exactly that one port
|
||||||
|
```
|
||||||
|
|
||||||
|
Two caveats. A fixed data port serves **one session at a time**; a second concurrent session finds it
|
||||||
|
busy and transparently falls back to a random port + hole-punch (logged). And `--data-port` streams
|
||||||
|
to the client's *reported* address, so use it only where that address is reachable — a flat LAN, or a
|
||||||
|
port-forward that doesn't remap the client's source. Leave it **off** (the default) to keep the
|
||||||
|
NAT-crossing hole-punch. On a normal single-LAN setup you can also just leave the data port closed and
|
||||||
|
accept the one-time ~2.5 s punch-timeout, or not run a host firewall on a trusted LAN at all.
|
||||||
|
|
||||||
## `nvidia-smi` says it can't communicate with the driver
|
## `nvidia-smi` says it can't communicate with the driver
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { SectionDisplays } from "@/sections/Displays";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/displays")({ component: SectionDisplays });
|
||||||
@@ -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 "∞";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 />} />
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user