3 Commits

Author SHA1 Message Date
enricobuehler e27718b406 packaging: ship firewalld services on rpm + deb too, share from packaging/linux
apple / swift (push) Successful in 1m10s
apple / screenshots (push) Successful in 5m45s
android / android (push) Successful in 4m2s
arch / build-publish (push) Successful in 5m37s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m9s
ci / rust (push) Successful in 4m39s
deb / build-publish (push) Successful in 2m56s
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 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
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 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m8s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m54s
Mirror the Arch firewalld service definitions into the RPM spec and the Debian
host package so every Linux packager installs them, and move the two XML files
to the shared packaging/linux/ home (alongside the .desktop files both the
PKGBUILD and deb scripts already source there) so there's one source of truth
instead of three drifting copies.

- rpm: install punktfunk-{gamestream,native}.xml to /usr/lib/firewalld/services/,
  list them in %files host, and print the firewalld enable command in %post
  (gated on firewall-cmd). Fedora/RHEL run firewalld by default, so this is where
  it matters most; Bazzite inherits it via the sysext built from the package /usr.
- deb: install both XMLs in build-deb.sh and add the same firewalld-gated hint to
  the postinst. Debian/Ubuntu ship no active firewall, so it's a no-op unless the
  admin runs firewalld.
- PKGBUILD + arch README updated to the packaging/linux/ path.
- Firewall docs (bazzite README now leads with --add-service; debian README gains
  a firewalld block) point at the shipped services; XML comments made
  distro-neutral. Never auto-enabled — packages don't touch the admin's firewall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:37:45 +00:00
enricobuehler 6bc893e394 docs(arch): fish-safe repo setup, firewalld services, fix client label
apple / screenshots (push) Successful in 5m25s
android / android (push) Has been cancelled
apple / swift (push) Successful in 1m13s
ci / rust (push) Successful in 5m26s
arch / build-publish (push) Successful in 6m6s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 2m58s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
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 5s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m5s
docker / deploy-docs (push) Successful in 20s
The pacman-repo setup step used a bash heredoc (`<<'EOF'`), which fish — the
default shell on CachyOS — cannot parse ("expected a string, but found a
redirection"). Replace it with a cross-shell `printf | sudo tee -a` form in both
the Arch guide and packaging/arch/README.md; `$repo`/`$arch` stay literal for
pacman and the output is byte-identical to the old heredoc.

Firewall: stock Arch ships none (ports already open), but CachyOS enables
firewalld by default and an Arch package must never touch the running firewall.
Ship firewalld service definitions the host package installs to
/usr/lib/firewalld/services/ (punktfunk-gamestream, punktfunk-native), not
auto-enabled; the install scriptlet prints the enable command only when
firewall-cmd is present. Document it in the Arch guide (new section) and README.
The mgmt API (loopback) and web console ports are deliberately not opened.

Also fix the "GTK4 couch/Deck client" mislabel — it's the native
GTK4/libadwaita Linux client (desktop/laptop/Deck are targets; the
controller-optimized launcher is one view, not its identity) — across the Arch
PKGBUILD/README, Arch guide, and the Debian README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:31:53 +00:00
enricobuehler f0d015fc45 fix(apple/macos): drop the rejected audioanalyticsd sandbox exception
apple / swift (push) Successful in 1m18s
arch / build-publish (push) Successful in 5m4s
release / apple (push) Successful in 8m16s
ci / rust (push) Successful in 6m2s
android / android (push) Successful in 11m29s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 3m1s
decky / build-publish (push) Successful in 24s
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 4s
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 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
ci / bench (push) Successful in 4m46s
App Review declined 0.4.2 (3384) under guideline 2.4.5(i): the temporary
com.apple.security.temporary-exception.mach-lookup.global-name =
com.apple.audioanalyticsd exception "is not appropriate and will not be
granted." It had been added on the theory that CoreHaptics controller
rumble (RumbleRenderer / MenuHaptics) hard-crashes under the App Sandbox
without it, since the framework reaches the audio-analytics daemon over
Mach and the sandbox denies that global-name lookup.

Tested the theory directly on macOS with a real Xbox pad: a
CHHapticEngine start + full-intensity rumble in a genuinely enforced
sandbox (NSHomeDirectory redirected into the app container) with no
exception on the codesigned binary runs fine — no crash — even with a
live AVAudioEngine stream running concurrently. CoreHaptics tolerates
the denied lookup; the exception was never load-bearing.

So just remove it: CoreHaptics session rumble and menu haptics keep
working on macOS unchanged (no source change needed). DualSense stays on
its raw-HID path — a genuine Sony-motor gap — which needs no exception
either.

Resubmit requires a new build number and clearing the App Store Connect
App Sandbox entitlement-usage justification for this exception.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:21:25 +02:00
27 changed files with 232 additions and 3374 deletions
+1 -542
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.7.4"
"version": "0.6.0"
},
"paths": {
"/api/v1/clients": {
@@ -138,172 +138,6 @@
}
}
},
"/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` is rejected until the\ndisplay-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release\npath yet).",
"operationId": "setDisplaySettings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayPolicy"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Policy stored; the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"400": {
"description": "An option value is not yet supported (e.g. keep_alive forever)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Policy could not be persisted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/state": {
"get": {
"tags": [
"display"
],
"summary": "Live virtual displays",
"description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.",
"operationId": "getDisplayState",
"responses": {
"200": {
"description": "The live/kept virtual displays",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayStateResponse"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/gpus": {
"get": {
"tags": [
@@ -1767,59 +1601,6 @@
"av1"
]
},
"ApiDisplayInfo": {
"type": "object",
"description": "One live or kept virtual display.",
"required": [
"slot",
"backend",
"mode",
"state",
"sessions"
],
"properties": {
"backend": {
"type": "string",
"description": "Backend name (`pf-vdisplay`, `kwin`, …)."
},
"client": {
"type": [
"string",
"null"
],
"description": "Short client label, when the owner tracks it."
},
"expires_in_ms": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
"minimum": 0
},
"mode": {
"type": "string",
"description": "`WIDTHxHEIGHT@HZ`."
},
"sessions": {
"type": "integer",
"format": "int32",
"description": "Live sessions holding the display.",
"minimum": 0
},
"slot": {
"type": "integer",
"format": "int64",
"description": "Stable-enough id for the `/display/release` `slot` argument.",
"minimum": 0
},
"state": {
"type": "string",
"description": "`active` | `lingering` | `pinned`."
}
}
},
"ApiError": {
"type": "object",
"description": "Error envelope for every non-2xx response.",
@@ -2128,130 +1909,6 @@
}
}
},
"DisplayPolicy": {
"type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"preset": {
"$ref": "#/components/schemas/Preset"
},
"topology": {
"$ref": "#/components/schemas/Topology"
},
"version": {
"type": "integer",
"format": "int32",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
"minimum": 0
}
}
},
"DisplaySettingsState": {
"type": "object",
"description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).",
"required": [
"settings",
"configured",
"effective",
"presets",
"enforced"
],
"properties": {
"configured": {
"type": "boolean",
"description": "True once a `display-settings.json` exists (the console has configured this host)."
},
"effective": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective (preset-expanded) policy currently in force."
},
"enforced": {
"type": "array",
"items": {
"type": "string"
},
"description": "Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining\nstored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the\nconsole can mark them \"coming soon\" instead of implying they already take effect."
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PresetInfo"
},
"description": "Every named preset and what it expands to (for the picker's preview)."
},
"settings": {
"$ref": "#/components/schemas/DisplayPolicy",
"description": "The stored policy (preset + custom fields), or the built-in default when unconfigured."
}
}
},
"DisplayStateResponse": {
"type": "object",
"description": "The host's managed virtual displays right now.",
"required": [
"displays"
],
"properties": {
"displays": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiDisplayInfo"
}
}
}
},
"EffectivePolicy": {
"type": "object",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
"required": [
"keep_alive",
"topology",
"mode_conflict",
"identity",
"layout",
"max_displays"
],
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"topology": {
"$ref": "#/components/schemas/Topology"
}
}
},
"GameEntry": {
"type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
@@ -2442,72 +2099,6 @@
}
}
},
"Identity": {
"type": "string",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
"enum": [
"shared",
"per-client",
"per-client-mode"
]
},
"KeepAlive": {
"oneOf": [
{
"type": "object",
"description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s).",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"off"
]
}
}
},
{
"type": "object",
"description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.",
"required": [
"seconds",
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"duration"
]
},
"seconds": {
"type": "integer",
"format": "int32",
"description": "Linger window in seconds.",
"minimum": 0
}
}
},
{
"type": "object",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"forever"
]
}
}
}
],
"description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple."
},
"LaunchSpec": {
"type": "object",
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
@@ -2527,32 +2118,6 @@
}
}
},
"Layout": {
"type": "object",
"description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).",
"properties": {
"mode": {
"$ref": "#/components/schemas/LayoutMode"
},
"positions": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
"propertyNames": {
"type": "string"
}
}
}
},
"LayoutMode": {
"type": "string",
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
"enum": [
"auto-row",
"manual"
]
},
"LocalSummary": {
"type": "object",
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
@@ -2677,16 +2242,6 @@
}
}
},
"ModeConflict": {
"type": "string",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
"enum": [
"separate",
"steal",
"join",
"reject"
]
},
"NativeClient": {
"type": "object",
"description": "A paired native (punktfunk/1) client.",
@@ -2884,88 +2439,6 @@
}
}
},
"Position": {
"type": "object",
"description": "A desktop-space offset for a display (top-left origin).",
"required": [
"x",
"y"
],
"properties": {
"x": {
"type": "integer",
"format": "int32"
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"Preset": {
"type": "string",
"description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).",
"enum": [
"custom",
"default",
"gaming-rig",
"shared-desktop",
"hotdesk",
"workstation"
]
},
"PresetInfo": {
"type": "object",
"description": "One preset's human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.",
"required": [
"id",
"summary",
"fields"
],
"properties": {
"fields": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective policy this preset expands to (the same fields a `custom` policy carries)."
},
"id": {
"type": "string",
"description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)."
},
"summary": {
"type": "string",
"description": "One-line story shown next to the option."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
"properties": {
"slot": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Slot to release (see `state`); omit to release **all** kept displays.",
"minimum": 0
}
}
},
"ReleaseDisplayResult": {
"type": "object",
"description": "Result of a `/display/release`.",
"required": [
"released"
],
"properties": {
"released": {
"type": "integer",
"description": "Number of kept displays torn down.",
"minimum": 0
}
}
},
"RuntimeStatus": {
"type": "object",
"description": "Live host status (changes as clients launch/end sessions).",
@@ -3267,16 +2740,6 @@
"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": {
@@ -3300,10 +2763,6 @@
"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"
@@ -48,21 +48,21 @@
<key>com.apple.security.device.usb</key>
<true/>
<!-- Controller rumble via CoreHaptics: GCDeviceHaptics.createEngine → CHHapticEngine
(GamepadFeedback's RumbleRenderer), and AVAudioEngine playback, reach the system
audio-analytics daemon `com.apple.audioanalyticsd` over Mach. The sandbox denies that
global-name lookup unless it's whitelisted here, and the framework's own precondition
turns the denial into a HARD CRASH ("Process is sandboxed but
com.apple.security.exception.mach-lookup.global-name doesn't contain
com.apple.audioanalyticsd") the moment a controller's haptics engine starts. This
temporary exception is the documented, App-Store-acceptable way to permit exactly that
lookup — and ONLY that service (the key takes exact names, no wildcards). App Store:
declare it in App Store Connect → App Sandbox Entitlement Usage Information ("CoreHaptics
gamepad rumble contacts the system audio-analytics daemon"). -->
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>com.apple.audioanalyticsd</string>
</array>
<!-- NO mach-lookup temporary exception here — and none is needed. Build 0.4.2 (3384) shipped a
`com.apple.security.temporary-exception.mach-lookup.global-name` = com.apple.audioanalyticsd
exception on the THEORY that CoreHaptics controller rumble (CHHapticEngine — the session
RumbleRenderer + MenuHaptics) hard-crashes under the App Sandbox without it, because the
framework reaches the audio-analytics daemon over Mach and the sandbox denies that lookup.
App Review REJECTED the exception under guideline 2.4.5(i) (review 2026-07-04). We then
tested the premise directly on macOS: a CHHapticEngine start + full-intensity rumble on a
real Xbox pad, in a genuinely ENFORCED sandbox (NSHomeDirectory redirected into the app
container) with NO exception on the codesigned binary — and it ran WITHOUT crashing, rumble
and all, even with a live AVAudioEngine stream running concurrently. CoreHaptics simply
tolerates the denied audioanalyticsd lookup (it's telemetry, not a hard precondition). So
controller rumble works fully sandboxed with none of these exceptions. Do NOT re-add one —
it will be rejected again AND it buys nothing. (DualSense rumble separately goes over raw
HID via device.usb/device.bluetooth — CoreHaptics genuinely doesn't drive Sony motors on
macOS — but that path needs no exception either; see DualSenseHID.) -->
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the
punktfunk/1 client identity in the data-protection keychain is gated by the app's
-309
View File
@@ -156,10 +156,6 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_compositors))
.routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference))
.routes(routes!(get_display_settings))
.routes(routes!(set_display_settings))
.routes(routes!(get_display_state))
.routes(routes!(release_display))
.routes(routes!(get_status))
.routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients))
@@ -214,7 +210,6 @@ pub fn openapi_json() -> String {
tags(
(name = "host", description = "Host identity, capabilities, and liveness"),
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
(name = "clients", description = "Paired Moonlight client management"),
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
@@ -959,242 +954,6 @@ async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> 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 (e.g. `keep_alive`, `topology`). The remaining
/// stored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the
/// console can mark them "coming soon" instead of implying they already take effect.
enforced: Vec<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()],
}
}
/// 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` is rejected until the
/// display-lifecycle stage ships (it would keep physical monitors dark indefinitely with no release
/// path yet).
#[utoipa::path(
put,
path = "/display/settings",
tag = "display",
operation_id = "setDisplaySettings",
request_body = crate::vdisplay::policy::DisplayPolicy,
responses(
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
(status = BAD_REQUEST, description = "An option value is not yet supported (e.g. keep_alive forever)", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn set_display_settings(
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
) -> Response {
use crate::vdisplay::policy::KeepAlive;
// Reject options this build can't honor yet, so the console can't promise a behavior that won't
// happen. `keep_alive: forever` (directly or via the `gaming-rig` preset) needs the Pinned
// lifecycle + a release path; until then it would strand physical monitors dark.
if policy.effective().keep_alive == KeepAlive::Forever {
return api_error(
StatusCode::BAD_REQUEST,
"keep_alive `forever` (and the `gaming-rig` preset) is not available yet — it arrives \
with the display-lifecycle stage. Use a fixed duration for now.",
);
}
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist display policy: {e:#}"),
);
}
tracing::info!("management API: display policy updated");
Json(display_settings_state()).into_response()
}
/// 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>,
}
/// 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,
})
.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 })
}
/// Live host status
#[utoipa::path(
get,
@@ -2714,74 +2473,6 @@ mod tests {
.unwrap()
}
/// The display-management endpoints: GET returns the policy surface (presets + effective +
/// the Stage-0 enforced list); PUT rejects `keep_alive: forever` (the `gaming-rig` preset)
/// *before* persisting, so this stays read-only against the global policy store.
#[tokio::test]
async fn display_settings_surface_and_forever_rejected() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["presets"].as_array().map(|a| a.len()),
Some(5),
"all five named presets are surfaced for the console picker"
);
assert!(
body["effective"]["keep_alive"].is_object(),
"the effective policy is echoed"
);
let enforced: Vec<&str> = body["enforced"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(enforced.contains(&"keep_alive") && enforced.contains(&"topology"));
// `gaming-rig` expands to keep_alive: forever → rejected at Stage 0 (before any write).
let put = axum::http::Request::put("/api/v1/display/settings")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({ "preset": "gaming-rig" }).to_string(),
))
.unwrap();
let (status, body) = send(&app, put).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("forever"),
"the rejection names the unsupported option"
);
}
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
/// created a display (and non-Windows reports none), so `/state` is empty and `/release` is a
/// no-op — the shapes + the "nothing to release" path, without touching any global owner.
#[tokio::test]
async fn display_state_and_release_empty() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/state")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["displays"].as_array().map(|a| a.len()),
Some(0),
"no managed displays on an idle test host"
);
let (status, body) = send(
&app,
post_json("/api/v1/display/release", serde_json::json!({})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["released"], 0);
}
#[tokio::test]
async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new(
+11 -67
View File
@@ -405,41 +405,18 @@ pub fn apply_session_env(active: &ActiveSession) {
}
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so
// the panels + windows land on the streamed surface, not an unstreamed real output (the
// auto-detected desktop path *is* "stream this desktop"). The per-compositor backends read
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`; drive it here from the display-management topology.
//
// Stage 0 keeps today's behavior exactly UNLESS the console configured a policy: when a
// `display-settings.json` exists, the effective topology wins (Exclusive → sole desktop,
// Extend → leave the streamed output extended, Primary → treated as Exclusive until the
// primary-only path lands in the topology stage). Unconfigured hosts fall through to the
// historical default-on-for-desktop behavior, honoring an explicit operator env var.
let var = match active.kind {
ActiveKind::DesktopKde => Some("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY"),
ActiveKind::DesktopGnome => Some("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY"),
_ => None,
};
if let Some(var) = var {
match policy::prefs().configured_effective() {
Some(eff) => {
let sole = match resolve_topology(eff.topology) {
policy::Topology::Extend => false,
policy::Topology::Exclusive => true,
policy::Topology::Primary => {
tracing::info!(
"display policy: topology=primary treated as exclusive at this stage \
(primary-only lands in the topology stage)"
);
true
}
// resolve_topology never returns Auto.
policy::Topology::Auto => true,
};
std::env::set_var(var, if sole { "1" } else { "0" });
}
// Unconfigured: today's behavior — default-on unless the operator set it explicitly.
None if std::env::var_os(var).is_none() => std::env::set_var(var, "1"),
None => {}
// auto-detected desktop path *is* "stream this desktop"). Default-on for the auto path; an
// explicit `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` still wins.
match active.kind {
ActiveKind::DesktopKde if std::env::var_os("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY").is_none() => {
std::env::set_var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY", "1");
}
ActiveKind::DesktopGnome
if std::env::var_os("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY").is_none() =>
{
std::env::set_var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY", "1");
}
_ => {}
}
}
#[cfg(not(target_os = "linux"))]
@@ -746,39 +723,6 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(())
}
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
// it), so no cfg gate. See `design/display-management.md`.
#[path = "vdisplay/policy.rs"]
pub(crate) mod policy;
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
// property-tested; the registry executes the side effects its transitions dictate.
#[path = "vdisplay/lifecycle.rs"]
pub(crate) mod lifecycle;
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
// later), for the management API's /display/state + /display/release.
#[path = "vdisplay/registry.rs"]
pub(crate) mod registry;
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
/// Linux desktop path, where "stream this desktop" means promoting the virtual output to sole).
pub fn resolve_topology(t: policy::Topology) -> policy::Topology {
match t {
policy::Topology::Auto => {
if crate::config::config().compositor.is_some() {
policy::Topology::Extend
} else {
policy::Topology::Exclusive
}
}
concrete => concrete,
}
}
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
#[cfg(target_os = "linux")]
@@ -1,338 +0,0 @@
//! 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"
);
}
}
}
@@ -1,573 +0,0 @@
//! 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
}
}
/// 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 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);
}
}
@@ -1,80 +0,0 @@
//! Neutral **facade over the per-OS virtual-display lifecycle owners**, for the management API's
//! `/display/state` + `/display/release` (design: `design/display-management.md` §7).
//!
//! Windows already owns its display lifecycle in [`super::manager::VirtualDisplayManager`] (one
//! shared IddCx monitor, refcounted, lingering); this facade reads and controls it. Linux keep-alive
//! (a per-session output pool driven by [`super::lifecycle`]) lands in a following increment — it
//! needs on-glass validation on a GPU box, which the current headless VM can't provide — so until
//! then the Linux side reports no managed displays and release is a no-op.
//!
//! The lifecycle *state machine* ([`super::lifecycle::State`]) is the platform-neutral core both
//! sides converge on; Windows adopts it when its manager is refactored onto it (that unification is
//! deferred so the on-glass-validated Windows path stays untouched this stage).
/// One live or kept virtual display, for the mgmt snapshot.
#[derive(Clone, Debug)]
pub struct DisplayInfo {
/// A stable-enough id for the `/display/release` slot argument (the backend's generation stamp).
pub slot: u64,
/// Backend name (`"pf-vdisplay"`, `"kwin"`, …).
pub backend: String,
/// `(width, height, refresh_hz)`.
pub mode: (u32, u32, u32),
/// `"active"` | `"lingering"` | `"pinned"`.
pub state: String,
/// Milliseconds until a lingering display is torn down (`None` when active/pinned).
pub expires_in_ms: Option<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>,
}
/// The live display set for the mgmt `/display/state` endpoint.
#[derive(Clone, Debug, Default)]
pub struct Snapshot {
pub displays: Vec<DisplayInfo>,
}
/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read);
/// safe per management request.
pub fn snapshot() -> Snapshot {
#[cfg(target_os = "windows")]
{
let displays = super::manager::snapshot()
.map(|i| DisplayInfo {
slot: i.gen,
backend: i.backend.to_string(),
mode: i.mode,
state: i.state.to_string(),
expires_in_ms: i.expires_in_ms,
sessions: i.sessions,
client: None,
})
.into_iter()
.collect();
Snapshot { displays }
}
#[cfg(not(target_os = "windows"))]
{
// Linux keep-alive pool: not yet (needs GPU-box validation) — no managed displays to report.
Snapshot::default()
}
}
/// Force-release kept (lingering/pinned) displays now — the `/display/release` endpoint. `slot`
/// selects one by [`DisplayInfo::slot`]; `None` releases every kept display. Active displays are
/// refused (releasing a display with live sessions is session management). Returns the number
/// released.
pub fn release(_slot: Option<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.)
usize::from(super::manager::force_release())
}
#[cfg(not(target_os = "windows"))]
{
0
}
}
@@ -634,15 +634,13 @@ impl VirtualDisplayManager {
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
if should_isolate() {
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed
// memory crosses). It runs under the `state` lock, the sole mutator of the topology.
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
} else {
tracing::info!(
"display isolation skipped (topology=extend / PUNKTFUNK_NO_ISOLATE) — IDD stays extended"
);
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
}
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
}
@@ -892,119 +890,10 @@ fn resolve_render_pin() -> Option<LUID> {
crate::win_adapter::resolve_render_adapter_luid()
}
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
/// maps it into the wire shape.
pub(crate) struct ManagedInfo {
pub backend: &'static str,
pub mode: (u32, u32, u32),
/// `"active"` | `"lingering"`.
pub state: &'static str,
/// Milliseconds until a lingering monitor is torn down (`None` when active).
pub expires_in_ms: Option<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))
}
};
Some(ManagedInfo {
backend: self.driver.name(),
mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz),
state,
expires_in_ms,
sessions,
gen: mon.gen,
})
}
/// Force-tear-down a LINGERING monitor now (the `/display/release` endpoint) — so a
/// physical-screen user gets their screen back without waiting out the linger. An Active monitor
/// is refused (stopping a live session is session management, not display management). Returns
/// `true` if a lingering monitor was released.
pub(crate) fn force_release(&self) -> bool {
let Some(dev) = self.device_handle() else {
return false;
};
let mut st = self.state.lock().unwrap();
if matches!(&*st, MgrState::Lingering { .. }) {
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *st, MgrState::Idle) {
// SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()`
// (cached handles are never closed — a dead one is retired, kept alive; see
// `DeviceSlot`). `mon` was moved out of the `Lingering` state under the `state` lock,
// so it is exclusively owned here — no aliasing.
unsafe { self.teardown(dev, mon) };
return true;
}
}
false
}
}
/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no
/// session has ever run) or it is Idle. Safe to call per management request.
pub(crate) fn snapshot() -> Option<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.
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
fn linger_ms() -> u64 {
use crate::vdisplay::policy::{prefs, Linger};
if let Some(eff) = prefs().configured_effective() {
return match eff.keep_alive.linger() {
Linger::Immediate => 0,
Linger::For(d) => d.as_millis() as u64,
// Pinned (keep forever) is built in the display-lifecycle stage; until then fall back to
// the default rather than silently keeping the monitor — and thus the physical screens —
// dark indefinitely. (The mgmt PUT also rejects `forever` at Stage 0, so this is defensive.)
Linger::Forever => {
tracing::warn!(
"display policy: keep_alive=forever not yet honored — lingering 10 s \
(Pinned lands in the display-lifecycle stage)"
);
10_000
}
};
}
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
/// Should a freshly-created monitor isolate the desktop to itself (disable the other displays)? The
/// console policy's effective topology wins when configured — `Extend` leaves the IDD extended,
/// `Exclusive`/`Primary` isolate (Stage 0 treats `Primary` as `Exclusive`); otherwise the legacy
/// `PUNKTFUNK_NO_ISOLATE` env knob (unset ⇒ isolate, matching today's default).
fn should_isolate() -> bool {
use crate::vdisplay::policy::Topology;
if let Some(eff) = crate::vdisplay::policy::prefs().configured_effective() {
return !matches!(
crate::vdisplay::resolve_topology(eff.topology),
Topology::Extend
);
}
std::env::var("PUNKTFUNK_NO_ISOLATE").is_err()
}
-732
View File
@@ -1,732 +0,0 @@
# Virtual-display management & lifecycle policy — design
> **Status:** PLANNED (nothing implemented). This doc designs a **policy layer on top of the
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second
> client wants a different mode), stable display identity (so desktop environments remember
> per-client settings like scaling), and **multi-monitor** (several virtual displays forming one
> desktop, fed by one client or by several). The `VirtualDisplay` trait and the per-backend
> `create()` mechanics stay as they are; this layer decides *when* to create, *how many*, *how
> long* to keep, *what else* to do to the topology, and *under which identity*.
Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md`
(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent,
not required).
## 1. Goal
Today the virtual-display behavior is hardcoded per platform and per backend:
- A session's virtual output is created at connect and torn down (RAII) at session end — a
disconnect destroys the display, reshuffles the desktop, and (on gamescope bare-spawn) **kills
the running game**.
- "Make the streamed output the sole desktop" is an env knob on Linux
(`PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY`, default-on for the
auto-detected desktop path) and default-on on Windows (`PUNKTFUNK_NO_ISOLATE` to opt out) —
and on Linux "primary" and "disable the other outputs" are conflated into one switch.
- What happens when a second client connects is an emergent property of the platform: Linux
creates a second output (multi-view), Windows **reconfigures the shared monitor under the
live session** (join-path `reconfigure` in `vdisplay/windows/manager.rs::acquire`), GameStream
preempts.
- Only Windows gives a client a stable monitor identity (`vdisplay/windows/identity.rs`), so only
Windows reapplies per-client display config (DPI scaling) across reconnects. On KDE every
session's output is `Virtual-punktfunk` at whatever mode — scaling has to be re-set per connect
and is shared across every client.
- One session = exactly one display. A client with two physical monitors can only stream one;
a tablet can't join an existing streamed desktop *as a second monitor* on purpose (the Linux
multi-view behavior half-does it by accident, with no layout control).
Goal: **one shared, documented configuration surface** — a small set of orthogonal options with
safe defaults and selectable presets, stored host-side, editable from the web console, applied
uniformly across the punktfunk/1 and GameStream paths and across all five backends (KWin,
gamescope, Mutter, wlroots, Windows pf-vdisplay), each backend implementing what it can and
**honestly declining** what it can't (the same honest-downgrade convention as 4:4:4/10-bit).
## 2. What exists today (inventory)
The asymmetry worth internalizing: **Windows already has most of the machinery; Linux has none.**
| Mechanism | Windows (pf-vdisplay) | Linux (kwin/mutter/wlroots) | gamescope |
|---|---|---|---|
| Lifecycle owner | `VirtualDisplayManager` singleton — `Idle / Active{refs} / Lingering{until}` state machine, gen-stamped `MonitorLease` | none — session owns `VirtualOutput.keepalive`, capturer drop = teardown | managed path: debounced TV-session restore (`RESTORE_DEBOUNCE` 5 s) + warm-session reuse; spawn path: child dies with the session |
| Keep-alive after disconnect | linger, default 10 s (`PUNKTFUNK_MONITOR_LINGER_MS`) | none | managed: 5 s debounce (hardcoded) |
| Reuse on reconnect | join Active (refcount++) / adopt Lingering (with a dead-swapchain preempt for IDD) | none (always create fresh) | managed: reuses the warm session |
| Primary / exclusive | `isolate_displays_ccd` (exclusive), default on, restore on teardown | `apply_virtual_primary` = primary **and** disable others, env-gated, restore on drop; Mutter `make_virtual_primary` = sole monitor (APPLY_TEMPORARY) | n/a (own nested session) |
| Mode conflict | join-path silently reconfigures the shared monitor (last-wins) | each session gets its own output (multi-view) | managed: one session; spawn: one gamescope per client |
| Stable identity | `identity.rs` — cert-fp → id 1..=15 (EDID serial + ConnectorIndex), LRU, persisted `pf-vdisplay-identity.json` | none — KWin output always named `punktfunk`, sway `HEADLESS-N`, Mutter auto-serial | n/a |
| Multi-monitor | manager is single-monitor (driver supports 16 connectors) | N outputs happen to coexist (multi-view), no layout/group semantics | single-output nested session |
Design consequence: the plan is **not** "build a manager" — it's (a) extract the state machine
Windows already proved into a platform-neutral, unit-testable core, (b) give Linux the ownership
split it's missing (manager owns the keepalive, session holds a lease), (c) put a typed policy
in front of both, (d) extend identity to Linux where the compositor allows it, and (e) grow the
slot model into display **groups** so multi-monitor is an arrangement of slots, not a new system.
## 3. Architecture
Three new pieces, layered strictly **above** the `VirtualDisplay` trait (no backend rewrite):
```
┌────────────────────────────────────────────┐
mgmt API / console │ DisplayPolicy (vdisplay/policy.rs) │ pure config: schema,
host.env compat ───▶│ presets · layout · validation · persist │ presets, env-compat
└───────────────┬────────────────────────────┘
│ read per acquire/release (live-reload)
┌───────────────▼────────────────────────────┐
punktfunk/1 session │ DisplayRegistry (vdisplay/registry.rs) │ host-lifetime singleton:
GameStream session ─▶ acquire(identity, mode) → DisplayLease │ owns ManagedDisplay slots
mgmt /display/state │ release(lease) · linger timer · groups │ grouped per desktop,
└───────┬────────────────────────┬───────────┘ drives the pure Lifecycle
│ create()/drop keepalive │ reconfigure/topology/layout ops
┌────────────▼──────────┐ ┌──────────▼───────────────┐
│ Linux backends │ │ Windows │
│ kwin · gamescope · │ │ VirtualDisplayManager │
│ mutter · wlroots │ │ (existing; delegates its │
│ (unchanged trait) │ │ state decisions upward) │
└───────────────────────┘ └──────────────────────────┘
```
- **`vdisplay/policy.rs`** — the typed config (`DisplayPolicy`), preset expansion, JSON
persistence (`<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 new `Pinned` state.
**Rules.**
- Input devices (uinput pads, libei/EIS contexts) stay session-scoped — a disconnect reads to
the game as "controller unplugged", which games handle. (Keeping pads alive for kept sessions
is a possible later refinement; do not build it now.)
- The **launch command runs once per display creation, never per attach** — a reconnect to a
kept gamescope must not double-launch the game. Today launch already happens once per
`build_pipeline`-successful session; the invariant moves with the create into the registry.
- An explicit client **quit** (GameStream `cancel`/quit-app; a future punktfunk/1
`EndSession{quit}` control message — protocol growth, trailing-byte back-compat as usual)
bypasses keep-alive: the user said "stop the game", so tear down now. Plain disconnects and
connection losses honor the policy.
- Host shutdown tears everything down (RAII on exit, as today). A host crash leaves whatever
the OS reclaims — Wayland connections die with the process (compositor reclaims outputs),
spawned gamescopes die with the process group, the pf-vdisplay watchdog reaps monitors when
pings stop. No new orphan class.
- `keep_alive` + `topology=exclusive` means **physical monitors stay dark after disconnect**
until linger expiry / release. This is intended (gaming-rig) but must be loud in the docs, and
the release-now escape hatch (§8) must exist in the same release that ships `forever`.
### 5.2 `topology`
Splits the currently-conflated "primary" knob into three honest levels, **group-aware** (§6.1):
"exclusive" means *the managed virtual displays* are the only enabled outputs — never disable a
sibling slot; restore fires when the group's last display drops. Per-backend mapping:
| | extend | primary | exclusive |
|---|---|---|---|
| KWin | no-op | `kscreen-doctor output.X.primary` only | primary + disable non-managed others (today's `apply_virtual_primary` with a registry-driven filter, §6.1), restore-on-teardown |
| Mutter | no-op | `ApplyMonitorsConfig` incl. physicals, virtual primary | today's sole-monitor config (`make_virtual_primary`) extended to include all group members |
| wlroots | no-op | **unsupported** (no primary concept) → log + treat as extend | `swaymsg output <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; add `Pinned` |
| reconfigure kept display to a new mode | ✅ `set_custom_refresh` + kscreen mode | ✅ SIGKILL+respawn is the honest "reconfigure" (game restarts — docs say so) or decline → recreate | ✅ existing managed-mode set | — | ⚠ node is sized by negotiation; renegotiation unproven — fallback recreate | ✅ `output <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 (.248 GNOME / .48 Fedora KDE).
- **Stage 0 — policy + plumbing-lite.** `policy.rs` (schema/presets/persist/env-compat, fully
unit-tested), mgmt GET/PUT `/display/settings`, console card (settings only), docs page
skeleton with the presets/options tables. Behavior deltas limited to what existing knobs can
express: Windows linger reads the policy; Linux topology auto/extend/exclusive routes through
the existing primary code. *No lifecycle change yet — zero-risk adoption of the surface.*
- **Stage 1 — lifecycle core + Linux keep-alive (easy backends).** `lifecycle.rs` pure machine
(+proptests: no lost teardowns, no double-frees across arbitrary acquire/release/expiry
interleavings), `registry.rs`, the ownership split (`DisplayLease`/`CaptureSource` — the one
cross-cutting refactor, touches `capture_virtual_output` signatures on both OSes), keep-alive
live for **wlroots** and **gamescope-spawn** (the two backends where reuse is structurally
trivial), `/display/state` + `/display/release`, console live-list. Windows manager delegates
linger/pinned decisions to `lifecycle.rs` (its driver specifics untouched).
*Validate:* sway on this box (headless), gamescope spawn: connect → disconnect → verify
vkcube/game still runs → reconnect → same session, no relaunch.
- **Stage 2 — KWin/Mutter keep-alive + topology decoupling.** Kept-node PipeWire re-attach on
KWin and Mutter (each behind its validation; fallback recreate), `primary` (without disable)
on KWin/Mutter/Windows, `exclusive` on wlroots, restore paths regression-tested.
*Validate:* headless KDE session (the `run-headless-kde.sh` rig), GNOME box .248.
- **Stage 3 — identity.** Platform-neutral identity map + migration, per-slot KWin output
naming (+ the concurrent-session name-clash fix riding along), GameStream identity wiring,
optional `per-client-mode` keying, per-client `default_scale` on KWin.
*Validate on KDE:* connect client A → set 150 % scaling → disconnect → reconnect → scaling
reapplied; client B unaffected; `kwinoutputconfig.json` inspected for the named entries.
- **Stage 4 — mode-conflict admission.** Decision function wired into both handshakes, the
typed punktfunk/1 `busy` refusal, GameStream 503 path, the Windows silent-reconfigure →
`join`-default change (call it out in release notes — it's a behavior fix), `steal` victim
signaling reusing the stop-flag plumbing.
*Validate:* two probe clients loopback (`--mode` differing) under each policy value.
- **Stage 5 — §6A multi-client monitors.** Display groups, group-aware exclusive/primary/
restore (incl. the name-filter fix), layout auto-row + manual, `/display/layout`, console
arrangement table. Cheap: rides Stages 13 infrastructure, no protocol change.
*Validate:* two clients (probe + GTK) on the headless KDE box forming a 2-output desktop;
drag a window across; disconnect one → its slot lingers per policy, sibling unaffected,
restore only after both drop.
- **Stage 6 — §6B protocol + Linux host + GTK client.** `VIDEO_CAP_MULTI_DISPLAY`, control-
stream Add/Remove/DisplayAdded, per-flow nonce-salt derivation, per-display pipelines on
KWin/wlroots, input display-index routing, C ABI additions, GTK client multi-window
presenter, stats display dimension.
*Validate:* loopback probe requesting 2 displays → two decodable .h265 outs + per-display
0xCF; then a real dual-monitor Linux client against the KDE box.
- **Stage 7 — Windows multi-monitor** (§6.6: driver proto v3 per-monitor sealed rings, manager
slot map, Windows client multi-window, `separate` un-gated on Windows) — gated on driver CI +
on-glass, deliberately last.
- **Stage 8 — polish.** Docs page finalized with real console screenshots, tray count/release
(stretch), README/CLAUDE.md/host.env.example updates, `local/summary` count, macOS §6B
presenter (its own mini-stage when scheduled).
## 12. Risks & open questions
- **PipeWire node reuse after consumer detach (KWin/Mutter)** — the load-bearing unknown for
Stage 2. If a kept node won't renegotiate for a fresh consumer, keep-alive on those backends
degrades to "topology-stable but recreate-on-reconnect" (still valuable: no desktop reshuffle
when *paired with identity naming*). The fallback is designed in, so the stage can't strand.
- **KWin persistence of `Virtual-*` output config** — if KWin declines to persist virtual
outputs, per-client scaling on KDE needs punktfunk-side scale storage instead (the
`default_scale` adjunct already gives us the mechanism); identity naming stays worthwhile for
the name-clash fix alone.
- **KWin stored-mode vs requested-mode fights** under identity naming (§5.4) — mitigated by
our post-create mode apply + read-back; watch for it in Stage 3 validation.
- **Compositor ceilings on simultaneous virtual outputs** — load-bearing for §6A/§6B: probe
KWin's virtual-output count and Mutter's `RecordVirtual` count (≥2 monitors) empirically in
Stage 2/5; `max_displays` default 4 keeps us under any realistic ceiling.
- **Encoder session exhaustion** (§6.4) — NVENC caps × split-encode × concurrent sessions must
be budgeted in one place (the admission check), or a second display can silently break an
unrelated session's encode. Split-encode is disabled for multi-display sessions by design.
- **Per-display input mapping** — each Linux injector (libei, wlr, gamescope EIS) binds
absolute coordinates differently; the §6B display-index routing is per-injector work with
per-backend validation, not one generic patch.
- **Client-side multi-window fullscreen juggling** (§6.5) — per-monitor DPI on Windows, Spaces
on macOS, pointer capture across our own windows; the reason clients stage GTK/Windows first.
- **Idle kept displays burn resources** — a kept gamescope keeps the game rendering (GPU) at
full rate; a kept KWin output keeps compositing; every §6B display encodes at full rate.
Document; a later refinement could drop a kept session's refresh, out of scope here.
- **Security posture** — keep-alive keeps a user session composited/running unattended;
nothing is unlocked that wasn't, and admission still rides pairing. `steal` on `--open`
hosts is the one sharp edge → docs recommend `reject` there (§5.3). The mgmt endpoints are
bearer-only; `local/summary` exposes counts only. §6B's extra UDP flows reuse the hardened
core `Session` unchanged (per-flow salts derived, never reused) — no new crypto surface.
- **Mutter identity** — blocked on GNOME API surface; re-check per GNOME release.
+32 -8
View File
@@ -34,12 +34,10 @@ curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
# verifies signed packages against the key you just trusted.
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
# verifies signed packages against the key you just trusted. (printf, not a heredoc, so this
# works in fish too — CachyOS's default shell has no `<<EOF` support.)
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
| sudo tee -a /etc/pacman.conf >/dev/null
```
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
@@ -54,7 +52,7 @@ sudo pacman -S punktfunk-web # optional: the browser management console (
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
`punktfunk-client` (the GTK4 couch/Deck client) is in the same repo if this box is also a client.
`punktfunk-client` (the native GTK4 Linux client) is in the same repo if this box is also a client.
The host package ships the systemd **user** units, the udev rule, the UDP socket-buffer sysctl
tuning, and example configs. Updates later are just `sudo pacman -Syu`.
@@ -108,7 +106,33 @@ sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
[Forgot your Password?](/docs/forgot-password).
## 5. Connect a client
## 5. Open the firewall (if you have one)
**Stock Arch ships no firewall** — every port is already open, so you can skip this. But **CachyOS
enables `firewalld` by default**, and an Arch package never opens ports for you (it won't touch your
running firewall), so on CachyOS the host is unreachable until you allow it.
The `punktfunk-host` package installs **firewalld service definitions** for exactly this, so
enabling is one command. Reload once so firewalld sees the just-installed definition, add the
service, then reload to apply:
```sh
sudo firewall-cmd --reload
sudo firewall-cmd --permanent --add-service=punktfunk-native # the default native host
# --add-service=punktfunk-gamestream # …or add this for Moonlight compat
sudo firewall-cmd --reload
```
`punktfunk-native` opens the QUIC control port (UDP 9777) + mDNS discovery; add
`punktfunk-gamestream` as well if you run `serve --gamestream` (the fixed Moonlight ports + mDNS).
The media **data plane** uses an *ephemeral* UDP port per session (nothing fixed to open); a
restrictive firewall must also allow a UDP range. The web console (47992) and mgmt API (47990,
loopback-only) are **not** opened by these — reach the console from the host box, or open 47992
yourself if you want it on the LAN. Not on firewalld? See
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md#firewall)
for the `ufw`/`nftables` port lists.
## 6. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing** — arm it from the host's web console, which displays a 4-digit PIN to type into
+2 -8
View File
@@ -62,15 +62,9 @@ picture.
## Compositor-specific (Linux)
> **Managing virtual displays** — keep-alive after disconnect, exclusive vs. extend, and (on
> Windows/KDE) persistent per-client scaling — now has its own settings surface in the web console
> and `display-settings.json`. See [Virtual displays](/docs/virtual-displays). The two
> `*_VIRTUAL_PRIMARY` knobs and `PUNKTFUNK_MONITOR_LINGER_MS` below still work but are superseded by
> it (a settings file wins over them).
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
| `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_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). |
@@ -105,7 +99,7 @@ picture.
|---|---|---|
| `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. |
| `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). |
| `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_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. |
-1
View File
@@ -24,7 +24,6 @@
"pairing",
"---Configuration---",
"configuration",
"virtual-displays",
"host-cli",
"---Troubleshooting---",
"troubleshooting",
-133
View File
@@ -1,133 +0,0 @@
---
title: Virtual displays
description: Control how punktfunk creates, keeps alive, and arranges the virtual displays it streams — presets, keep-alive, exclusive vs. extend, and persistent per-client scaling.
---
When a client connects, punktfunk creates a **virtual display** sized to exactly that client's
resolution and refresh, renders your desktop or game onto it, and streams it. This page is about the
**policy** for that display: how long it survives a disconnect, whether it takes over your physical
monitors, what happens when a second client connects, and how desktop environments remember
per-client settings like scaling.
You set this policy in the **web console** (Host → *Virtual displays*), or by editing
`~/.config/punktfunk/display-settings.json` directly (`%ProgramData%\punktfunk\display-settings.json`
on Windows). A change applies to the **next** connection — a running session keeps the display it
opened on.
> **You rarely need to touch this.** The default behavior matches how punktfunk has always worked.
> Reach for a preset when you want a specific experience — a dedicated couch/gaming box, a desktop
> you also use in person, or a multi-monitor workstation.
> **What's live today:** this release wires **keep-alive** (linger duration) and **topology**
> (extend / primary / exclusive). The other options below — conflict handling, identity/scaling
> persistence on Linux, and multi-monitor layout — are **stored but not yet enforced**; they arrive
> in following releases. The console marks them accordingly. Windows already persists per-client
> scaling (see [Persistent scaling](#persistent-scaling)).
## Pick a preset
A preset is the easy way in — select one in the console and you're done. Each expands to a bundle of
the individual options documented further down.
| Preset | What it's for |
|---|---|
| **Default** | Today's behavior. A short linger absorbs reconnects, the streamed output becomes the sole desktop, and extra clients each get their own view. |
| **Gaming rig** | A dedicated couch/headless box. The game and its display survive disconnects indefinitely, and whoever connects takes the box over. *(Arrives with the keep-alive stage.)* |
| **Shared desktop** | A desktop you also use in person. punktfunk never blanks your real monitors and never leaves a ghost display behind; concurrent viewers each get a view. |
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
## Options reference
Choose **Custom** in the console to set these directly.
### Keep alive
How long the virtual display survives after your last session disconnects. On a gamescope game host,
this also keeps the **game itself running** so you can reconnect straight back into it.
- **Off** — tear the display down at session end (nothing lingers).
- **A duration** (seconds) — keep it for that long; a reconnect inside the window drops you straight
back in, with no re-negotiation and no desktop reshuffle.
- **Forever** — keep it until you stop the host or release it from the console. *(Arrives with the
keep-alive lifecycle stage; the console won't let you save it before then.)*
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
immediately — a short linger makes reconnects smoother on both.
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
> linger expires or you release the display. That's intentional for a dedicated gaming box, but
> don't set a long/forever keep-alive together with Exclusive on a machine whose monitors you also
> use in person — use **Shared desktop** there instead.
### Topology
What punktfunk does with your monitor layout while it streams.
- **Extend** — add the virtual display alongside your real monitors; touch nothing else.
- **Primary** — make the virtual display your primary output; your physical monitors stay on.
- **Exclusive** — the virtual display becomes your **only** enabled output (physical monitors are
disabled, then restored when streaming ends). This is what makes the streamed surface *be* the
desktop, so panels and windows land on it.
- **Automatic** *(default)* — Exclusive on Windows and on an auto-detected KDE/GNOME desktop
("stream this desktop" means the streamed output *is* the desktop); Extend when you've pinned a
specific compositor with `PUNKTFUNK_COMPOSITOR` (a test/CI posture).
Per-backend support:
| | KWin | Mutter/GNOME | Sway/wlroots | Windows |
|---|---|---|---|---|
| Extend | ✅ | ✅ | ✅ | ✅ |
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ *(following release)* |
| Exclusive | ✅ | ✅ | ✅ *(following release)* | ✅ |
### Conflict handling · identity · layout
These are **stored but not yet enforced** — they're documented here so you know what's coming and
can set them ahead of the release that turns them on:
- **Conflict handling** — what happens when a *different* client connects while one is already
streaming and asks for a different resolution: give it its own display (**separate**), take the
box over (**steal**), share the existing display at its current mode (**join**), or refuse it
(**reject**).
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
remembers its settings (see below): one shared identity, one **per client**, or one **per client +
resolution**.
- **Layout / max displays** — how multiple virtual displays are arranged (for multi-monitor), and an
upper bound on how many can be live at once.
## Persistent scaling
Set your display **scaling** once and have it stick across reconnects. This works by giving each
client a *stable display identity*, so your desktop environment keys its per-monitor settings to it.
| Host | Supported | How |
|---|---|---|
| **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. |
| **KDE / KWin** | ⏳ following release | A stable per-client output name lets KWin persist scale/mode per client. |
| **GNOME / Mutter** | ❌ | GNOME's virtual-monitor API exposes no stable identity to key config on. |
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
## Legacy environment knobs
These `PUNKTFUNK_*` variables still work, but the console (and `display-settings.json`) supersede
them — when a settings file exists, it wins.
| Legacy knob | Now expressed as |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | **Keep alive** → duration *(Windows)* |
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
## Troubleshooting
**My physical monitors stayed off after I disconnected.** You have keep-alive set together with
Exclusive topology — the display (and your isolated desktop) is being kept for the linger window.
Release it from the console (Host → *Virtual displays*), or switch to the **Shared desktop** preset
so streaming never disables your real monitors.
**The virtual output shows only my wallpaper.** Your topology is Extend, so the streamed display is
an empty extension. Use **Primary** or **Exclusive** so your desktop actually lands on it.
**KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all —
see [requirements](/docs/requirements).
+10 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: unom <noreply@anthropic.com>
#
# Arch Linux / SteamOS split package: punktfunk-host (the gaming-rig HOST, NVENC) and
# punktfunk-client (the GTK4 couch/Deck CLIENT). Mirrors the rpm subpackages
# punktfunk-client (the native GTK4/libadwaita Linux CLIENT). Mirrors the rpm subpackages
# (packaging/rpm/punktfunk.spec) and the two deb build scripts. On a Steam Deck you want
# `punktfunk-client` (it's what the Decky plugin launches); on a gaming rig, `punktfunk-host`.
#
@@ -134,13 +134,21 @@ package_punktfunk-host() {
install -Dm0644 "$R/packaging/bazzite/gamescope-headless-session" \
"$pkgdir/etc/gamescope-session-plus/sessions.d/steam"
install -Dm0644 "$R/api/openapi.json" "$pkgdir/usr/share/punktfunk/openapi.json"
# firewalld service definitions — NOT auto-enabled (Arch packages never touch the admin's
# firewall). Stock Arch ships none, so they're a no-op there; CachyOS et al. ship firewalld, so
# sudo firewall-cmd --reload && sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
# (or =punktfunk-native). See README.md → Firewall.
install -Dm0644 "$R/packaging/linux/punktfunk-gamestream.xml" \
"$pkgdir/usr/lib/firewalld/services/punktfunk-gamestream.xml"
install -Dm0644 "$R/packaging/linux/punktfunk-native.xml" \
"$pkgdir/usr/lib/firewalld/services/punktfunk-native.xml"
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-MIT"
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-APACHE"
install -Dm0644 "$R/README.md" "$pkgdir/usr/share/doc/punktfunk-host/README.md"
}
package_punktfunk-client() {
pkgdesc="Low-latency desktop/game streaming CLIENT (GTK4) — the couch/Deck side"
pkgdesc="Low-latency desktop/game streaming CLIENT — native GTK4/libadwaita Linux app"
# The GTK4/libadwaita client: SDL3 gamepads, FFmpeg (VAAPI) decode, PipeWire audio/mic.
depends=('gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wireplumber' 'pipewire-pulse'
'opus' 'libglvnd')
+37 -11
View File
@@ -1,9 +1,9 @@
# punktfunk on Arch Linux / SteamOS
Packaging for punktfunk on Arch and Arch-derived immutable distros. The `PKGBUILD` is a **split
package** producing **`punktfunk-host`** (the gaming-rig host) and **`punktfunk-client`** (the GTK4
couch/Deck client) — mirrors the rpm subpackages (`packaging/rpm/punktfunk.spec`) and the deb build
scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's what the
package** producing **`punktfunk-host`** (the gaming-rig host) and **`punktfunk-client`** (the native
GTK4/libadwaita Linux client) — mirrors the rpm subpackages (`packaging/rpm/punktfunk.spec`) and the
deb build scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's what the
[Decky plugin](../../clients/decky/) launches); on a gaming rig, `punktfunk-host`.
> **Steam Deck as a HOST:** don't use this PKGBUILD — SteamOS's read-only root makes `makepkg`/sysext
@@ -42,15 +42,13 @@ curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
# printf, not a heredoc, so this works in fish too (CachyOS's default shell has no `<<EOF`).
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
| sudo tee -a /etc/pacman.conf >/dev/null
# 3. Sync + install.
sudo pacman -Sy punktfunk-host # gaming rig
sudo pacman -Sy punktfunk-client # couch/Deck side
sudo pacman -Sy punktfunk-client # the native GTK4 Linux client
sudo pacman -Sy punktfunk-web # optional browser management console
```
@@ -139,7 +137,31 @@ so it's a much lighter sysext than the host.
## Firewall
If the host box runs a firewall, open the ports it listens on. The **native `punktfunk/1`** plane:
**Stock Arch ships no firewall** — every port is open by default, so there is nothing to do.
Spins that enable one **do not** get their ports opened for you: an Arch package never touches the
admin's running firewall. **CachyOS is the common case** — its installer turns on `firewalld` by
default, so out of the box the host is unreachable until you allow it.
The `punktfunk-host` package ships **firewalld service definitions** (installed to
`/usr/lib/firewalld/services/`) so enabling is one command — pick the plane your host serves:
```sh
# Reload once so firewalld picks up the just-installed service definition, add it, reload to apply.
sudo firewall-cmd --reload
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream # Moonlight/GameStream host
# --add-service=punktfunk-native # …or the native-only host
sudo firewall-cmd --reload
```
`punktfunk-gamestream` opens the fixed Moonlight ports + mDNS; `punktfunk-native` opens the QUIC
control port (UDP 9777) + mDNS. Enable both if the host runs `serve --gamestream` (which serves
both planes). The **data plane is an *ephemeral* UDP port** negotiated per session, so there is no
fixed data port in either service; a restrictive firewall must additionally allow a UDP range (the
project does not pin one). The mgmt REST API (TCP 47990) binds to loopback by default — leave it
closed unless you move it off loopback with `--mgmt-bind IP:PORT` (which then requires
`--mgmt-token`).
For a non-firewalld setup, open the ports directly. The **native `punktfunk/1`** plane:
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to
@@ -182,6 +204,10 @@ udp dport { 47998-48010, 5353 } accept
- `PKGBUILD` — split package: `punktfunk-host` + `punktfunk-client` (builds the working tree via
`PF_SRCDIR`, or a git tag for AUR).
- `punktfunk-host.install` / `punktfunk-client.install` — pacman scriptlets (udev reload + sysctl +
first-run hint), mirror the RPM `%post` / deb postinst.
first-run hint, incl. the firewalld enable command when firewalld is present), mirror the RPM
`%post` / deb postinst.
- The firewalld service definitions (`punktfunk-gamestream.xml` / `punktfunk-native.xml`) are shared
across all Linux packaging and live in [`../linux/`](../linux/); the host package installs them to
`/usr/lib/firewalld/services/` (not auto-enabled; see Firewall above).
- `build-sysext.sh` — wraps either built `.pkg.tar.zst` into a `systemd-sysext` `.raw` for SteamOS
(derives the name from the package, so it works for host or client).
+12
View File
@@ -17,6 +17,18 @@ punktfunk-host installed.
NOTE: encode is NVENC-only. Install 'nvidia-utils' on an NVIDIA host. An AMD Steam Deck is NOT
yet supported — it needs a VAAPI (hevc_vaapi) encoder backend (see packaging/arch/README.md).
MSG
# Firewall: stock Arch ships none (ports already open), but CachyOS et al. enable firewalld. We
# install firewalld service definitions but never touch the running firewall — just point the way.
if command -v firewall-cmd >/dev/null 2>&1; then
cat <<'MSG'
4. firewalld is active — open the streaming ports once (GameStream/Moonlight shown; use
'punktfunk-native' instead for the native-only host):
sudo firewall-cmd --reload # load the new service def
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream
sudo firewall-cmd --reload
MSG
fi
}
post_upgrade() {
+18 -5
View File
@@ -321,10 +321,23 @@ journalctl --user -u punktfunk-host -f
## 6. Firewall
> ⚠️ **There is no firewall script or firewall doc in the repo.** The ports below are derived
> directly from the code constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and
> the GameStream-host port-map (`design/gamestream-host-plan.md`). Treat the `firewall-cmd` lines as recommended-but-verified,
> not a checked-in script.
Bazzite runs **firewalld**, so the ports must be opened. The `punktfunk-host` package installs
firewalld **service definitions** (`/usr/lib/firewalld/services/punktfunk-gamestream.xml` and
`punktfunk-native.xml`), so enabling is one command — reload first so firewalld picks up the
definition, add the service, reload to apply:
```sh
sudo firewall-cmd --reload
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream # Moonlight/GameStream host
# --add-service=punktfunk-native # …or the native-only host
sudo firewall-cmd --reload
```
`punktfunk-gamestream` opens the fixed Moonlight ports + mDNS; `punktfunk-native` opens the QUIC
control port (UDP 9777) + mDNS. Enable both if the host runs `serve --gamestream` (both planes). The
per-port breakdown below is for reference (or for opening ports by hand); the ports are the code
constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and the GameStream-host port-map
(`design/gamestream-host-plan.md`).
**GameStream / Moonlight ports** (fixed; Moonlight derives them from the HTTP base). These only apply
when the host runs `serve --gamestream` (the bundled unit's default); on a bare-`serve` native-only
@@ -344,7 +357,7 @@ host you don't open them:
default**, so you do **not** open it in the firewall unless you deliberately move it off loopback
with `--mgmt-bind IP:PORT` (which also requires `--mgmt-token`). Leave it closed for a normal setup.
Open the GameStream ports with `firewalld` (Bazzite uses firewalld):
To open the GameStream ports by hand instead of the service (equivalent):
```sh
sudo firewall-cmd --permanent --add-port=47984/tcp \
+15 -2
View File
@@ -9,7 +9,7 @@ to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/chan
below subscribes to `stable`; swap `stable``canary` for the latest main builds.
The same workflow also publishes **`punktfunk-web`** (the browser management console — pairing +
status) and **`punktfunk-client`** (the GTK4 couch/Deck client). `punktfunk-host` **Recommends**
status) and **`punktfunk-client`** (the native GTK4/libadwaita Linux client). `punktfunk-host` **Recommends**
`punktfunk-web`, so a default `apt install punktfunk-host` pulls the console too (alongside the
udev/sysctl bits) unless you've disabled weak deps; `punktfunk-client` is independent — install it
on the box you stream *to*. (`punktfunk-probe` is the headless reference/test tool, not packaged
@@ -52,7 +52,20 @@ journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
## Firewall
Open the ports the host listens on. The **native `punktfunk/1`** plane:
**Debian ships no firewall and Ubuntu's `ufw` is installed-but-inactive by default**, so out of the
box there is nothing to open. If you run one, open the ports the host listens on.
If you use **firewalld**, the `punktfunk-host` package installs service definitions to
`/usr/lib/firewalld/services/` (not auto-enabled), so it's one command:
```sh
sudo firewall-cmd --reload # load the installed definition
sudo firewall-cmd --permanent --add-service=punktfunk-native # the default native host
# --add-service=punktfunk-gamestream # …add for Moonlight compat
sudo firewall-cmd --reload
```
Otherwise open the ports directly. The **native `punktfunk/1`** plane:
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to
+13
View File
@@ -80,6 +80,13 @@ install -Dm0644 scripts/host.env.example "$SHAREDIR/host.env.example"
install -Dm0644 packaging/bazzite/host.env "$SHAREDIR/host.env.bazzite"
install -Dm0644 packaging/kde/host.env "$SHAREDIR/host.env.kde"
install -Dm0644 api/openapi.json "$SHAREDIR/openapi.json"
# firewalld service definitions (shared across all Linux packaging). NOT auto-enabled — the postinst
# only prints the enable command when firewalld is present. Debian/Ubuntu ship no active firewall
# (Ubuntu's ufw is installed-but-inactive), so these are a no-op unless the admin runs firewalld.
install -Dm0644 packaging/linux/punktfunk-gamestream.xml \
"$STAGE/usr/lib/firewalld/services/punktfunk-gamestream.xml"
install -Dm0644 packaging/linux/punktfunk-native.xml \
"$STAGE/usr/lib/firewalld/services/punktfunk-native.xml"
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
install -Dm0644 README.md "$DOCDIR/README.md"
@@ -186,6 +193,12 @@ if [ "$1" = "configure" ]; then
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
# Debian/Ubuntu ship no active firewall; only hint firewalld users (ufw users: see README).
if command -v firewall-cmd >/dev/null 2>&1; then
echo "Firewall (firewalld detected): sudo firewall-cmd --reload &&"
echo " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload"
echo " (use punktfunk-native for the native-only host)"
fi
fi
exit 0
EOF
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
firewalld service definition for the punktfunk GameStream (Moonlight-compatible) host.
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. It is NOT enabled
automatically: an Arch package never touches the admin's running firewall. Stock Arch ships no
firewall (these ports are already open); Fedora/RHEL and CachyOS enable firewalld by default, so
enable it once with firewall-cmd (add-service=punktfunk-gamestream, then reload). Exact commands:
your distro's install guide, or the per-distro packaging README (Firewall section).
Needed only when the host runs GameStream/Moonlight compat (serve with the gamestream flag). The
mgmt REST API (TCP 47990) stays on loopback by default and is deliberately not opened here.
Port map: design/gamestream-host-plan.md.
-->
<service>
<short>Punktfunk (GameStream / Moonlight)</short>
<description>Low-latency game-streaming host over the Moonlight-compatible GameStream protocol. Opens the fixed nvhttp (HTTPS/HTTP), RTSP, video RTP, ENet control/input and Opus audio ports, plus mDNS for auto-discovery.</description>
<port protocol="tcp" port="47984"/> <!-- HTTPS nvhttp (paired, mutual TLS) -->
<port protocol="tcp" port="47989"/> <!-- HTTP nvhttp (/serverinfo, /pair PIN flow) -->
<port protocol="tcp" port="48010"/> <!-- RTSP handshake -->
<port protocol="udp" port="47998"/> <!-- Video RTP (+ FEC) -->
<port protocol="udp" port="47999"/> <!-- ENet control stream + remote input -->
<port protocol="udp" port="48000"/> <!-- Audio (Opus) -->
<port protocol="udp" port="5353"/> <!-- mDNS auto-discovery (_nvstream._tcp.local) -->
</service>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
firewalld service definition for the native punktfunk/1 host (the secure default 'serve', or the
punktfunk1-host subcommand).
Installed to /usr/lib/firewalld/services/ by the punktfunk-host package. NOT enabled automatically
(packages never touch the admin's firewall). Stock Arch/Debian ship no active firewall; Fedora/RHEL
and CachyOS enable firewalld by default, so enable it once with firewall-cmd
(add-service=punktfunk-native, then reload). Exact commands: your distro's install guide, or the
per-distro packaging README (Firewall section).
The media DATA plane binds an EPHEMERAL UDP port (0.0.0.0:0) chosen per session and reported to the
client, so there is no fixed data port to open. On a restrictive firewall you must also allow the
ephemeral UDP range (the project does not pin one).
-->
<service>
<short>Punktfunk (native punktfunk/1)</short>
<description>Low-latency game-streaming host over the native punktfunk/1 protocol (QUIC control plane). Opens the default QUIC control port plus mDNS for auto-discovery. The media data plane uses an ephemeral UDP port negotiated per session, not opened here.</description>
<port protocol="udp" port="9777"/> <!-- QUIC control plane (default 9777) -->
<port protocol="udp" port="5353"/> <!-- mDNS auto-discovery (_punktfunk._udp.local) -->
</service>
+15
View File
@@ -259,6 +259,13 @@ install -Dm0755 packaging/bazzite/kde-desktop-setup.sh %{buildroot}%{_datadir}/%
install -Dm0644 packaging/bazzite/gamescope-headless-session \
%{buildroot}/etc/gamescope-session-plus/sessions.d/steam
install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name}/openapi.json
# firewalld service definitions (shared across all Linux packaging). Fedora/RHEL enable firewalld by
# default, so these matter here; NOT auto-enabled — %post prints the enable command. Owned by the
# firewalld package's dir; we drop only the files (same pattern as the sysctl.d file above).
install -Dm0644 packaging/linux/punktfunk-gamestream.xml \
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
install -Dm0644 packaging/linux/punktfunk-native.xml \
%{buildroot}%{_prefix}/lib/firewalld/services/punktfunk-native.xml
%if %{with web}
# --- web console subpackage (punktfunk-web) ---
@@ -289,6 +296,8 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
%{_prefix}/lib/firewalld/services/punktfunk-native.xml
%{_userunitdir}/punktfunk-host.service
%{_userunitdir}/punktfunk-kde-session.service
%{_datadir}/applications/io.unom.Punktfunk.Host.desktop
@@ -340,6 +349,12 @@ sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || :
echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)"
echo "then enable the host: systemctl --user enable --now punktfunk-host"
echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env"
# Fedora/RHEL run firewalld by default — point the way to the installed service definitions.
if command -v firewall-cmd >/dev/null 2>&1; then
echo "Firewall (firewalld): sudo firewall-cmd --reload &&"
echo " sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload"
echo " (use punktfunk-native for the native-only host)"
fi
%if %{with web}
%post web
-30
View File
@@ -47,36 +47,6 @@
"gpu_none": "Keine GPUs erkannt.",
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
"host_displays": "Virtuelle Displays",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_preset": "Voreinstellung",
"display_preset_custom": "Benutzerdefiniert",
"display_preset_default": "Standard",
"display_preset_gaming_rig": "Gaming-Rig",
"display_preset_shared_desktop": "Geteilter Desktop",
"display_preset_hotdesk": "Hot-Desk",
"display_preset_workstation": "Workstation",
"display_keep_alive": "Nach Trennung aktiv halten",
"display_keep_alive_off": "Aus",
"display_keep_alive_seconds": "Sekunden",
"display_topology": "Topologie",
"display_topology_auto": "Automatisch",
"display_topology_extend": "Erweitern",
"display_topology_primary": "Primär",
"display_topology_exclusive": "Exklusiv",
"display_max": "Max. Displays",
"display_save": "Speichern",
"display_effective": "Aktiv",
"display_pending_note": "Konfliktbehandlung, Identität und Layout werden gespeichert, aber noch nicht angewendet — sie folgen in späteren Versionen.",
"display_live": "Aktive Displays",
"display_none_live": "Derzeit keine virtuellen Displays.",
"display_state_active": "Aktiv",
"display_state_lingering": "Wird gehalten",
"display_state_pinned": "Angeheftet",
"display_release_btn": "Freigeben",
"display_release_all": "Alle gehaltenen freigeben",
"display_expires_in": "Abbau in {sec}s",
"display_sessions": "{count} streamend",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
-30
View File
@@ -47,36 +47,6 @@
"gpu_none": "No GPUs detected.",
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
"host_displays": "Virtual displays",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_preset": "Preset",
"display_preset_custom": "Custom",
"display_preset_default": "Default",
"display_preset_gaming_rig": "Gaming rig",
"display_preset_shared_desktop": "Shared desktop",
"display_preset_hotdesk": "Hot-desk",
"display_preset_workstation": "Workstation",
"display_keep_alive": "Keep alive after disconnect",
"display_keep_alive_off": "Off",
"display_keep_alive_seconds": "seconds",
"display_topology": "Topology",
"display_topology_auto": "Automatic",
"display_topology_extend": "Extend",
"display_topology_primary": "Primary",
"display_topology_exclusive": "Exclusive",
"display_max": "Max displays",
"display_save": "Save",
"display_effective": "In effect",
"display_pending_note": "Conflict handling, identity, and layout are stored but not enforced yet — they arrive in later releases.",
"display_live": "Live displays",
"display_none_live": "No virtual displays right now.",
"display_state_active": "Active",
"display_state_lingering": "Lingering",
"display_state_pinned": "Pinned",
"display_release_btn": "Release",
"display_release_all": "Release all kept",
"display_expires_in": "tears down in {sec}s",
"display_sessions": "{count} streaming",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
-362
View File
@@ -1,362 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { type FC, useEffect, useState } from "react";
import {
getGetDisplayStateQueryKey,
getGetDisplaySettingsQueryKey,
useGetDisplaySettings,
useGetDisplayState,
useReleaseDisplay,
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type { ApiDisplayInfo } from "@/api/gen/model";
import { ApiError } from "@/api/fetcher";
import type {
DisplayPolicy,
EffectivePolicy,
KeepAlive,
Preset,
Topology,
} from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/**
* Container: the host's virtual-display management policy (design/display-management.md). Reads the
* stored policy + preset expansions, lets the operator pick a preset or set Custom fields, and PUTs
* the result — a change applies to the next session. Stage 0 enforces keep-alive + topology; the
* other stored options are shown but marked not-yet-enforced.
*/
export const DisplaySection: FC = () => {
const qc = useQueryClient();
const q = useGetDisplaySettings();
const save = useSetDisplaySettings();
// Local edit buffer, seeded once from the server and re-seeded after a successful save.
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
useEffect(() => {
if (q.data && draft === null) setDraft(q.data.settings);
}, [q.data, draft]);
const onSave = () => {
if (!draft) return;
save.mutate(
{ data: draft },
{
onSuccess: (res) => {
setDraft(res.settings);
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle>{m.host_displays()}</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}
onSave={onSave}
busy={save.isPending}
error={apiErrorMessage(save.error)}
/>
)}
</QueryState>
<LiveDisplays />
</CardContent>
</Card>
);
};
/**
* 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-2 border-t pt-4">
<div className="flex items-center justify-between gap-4">
<h4 className="text-sm font-medium">{m.display_live()}</h4>
{kept.length > 0 && (
<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>
)}
</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;
};
/** The `gaming-rig` preset expands to `keep_alive: forever`, which the host rejects until the
* display-lifecycle stage — disable it rather than let the Save 400. */
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
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<Topology, () => string> = {
auto: m.display_topology_auto,
extend: m.display_topology_extend,
primary: m.display_topology_primary,
exclusive: m.display_topology_exclusive,
};
const fmtKeepAlive = (k: KeepAlive): string => {
switch (k.mode) {
case "off":
return m.display_keep_alive_off();
case "duration":
return `${k.seconds} ${m.display_keep_alive_seconds()}`;
case "forever":
return "∞";
}
};
const DisplayForm: FC<{
draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
onSave: () => void;
busy: boolean;
error?: string;
}> = ({ draft, setDraft, presets, onSave, busy, error }) => {
const preset: Preset = draft.preset ?? "custom";
const isCustom = preset === "custom";
const keepAlive: KeepAlive = draft.keep_alive ?? { mode: "duration", seconds: 10 };
const topology: Topology = draft.topology ?? "auto";
// Preview the effective fields: from the selected preset's expansion, or the Custom fields.
const effective: EffectivePolicy | undefined = isCustom
? {
keep_alive: keepAlive,
topology,
mode_conflict: draft.mode_conflict ?? "separate",
identity: draft.identity ?? "per-client",
layout: draft.layout ?? { mode: "auto-row", positions: {} },
max_displays: draft.max_displays ?? 4,
}
: presets.find((p) => p.id === preset)?.fields;
const presetSummary = presets.find((p) => p.id === preset)?.summary;
const secondsValue = keepAlive.mode === "duration" ? keepAlive.seconds : 300;
return (
<div className="space-y-5">
{/* Preset picker */}
<div className="space-y-2">
<Label>{m.display_preset()}</Label>
<div className="flex flex-wrap gap-2">
{(["custom", "default", "gaming-rig", "shared-desktop", "hotdesk", "workstation"] as const).map(
(id) => (
<Button
key={id}
size="sm"
variant={preset === id ? "default" : "outline"}
disabled={busy || DISABLED_PRESETS.has(id)}
onClick={() => setDraft({ ...draft, preset: id as Preset })}
>
{(PRESET_LABEL[id] ?? (() => id))()}
</Button>
),
)}
</div>
{presetSummary && !isCustom && (
<p className="text-xs text-muted-foreground">{presetSummary}</p>
)}
</div>
{/* Custom fields: keep-alive + topology + max displays */}
{isCustom && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-2">
<Label>{m.display_keep_alive()}</Label>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={keepAlive.mode === "off" ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
>
{m.display_keep_alive_off()}
</Button>
<Input
type="number"
min={0}
className="w-24"
value={secondsValue}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
keep_alive: {
mode: "duration",
seconds: Math.max(0, Number(e.target.value) || 0),
},
})
}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</div>
<div className="space-y-2">
<Label>{m.display_topology()}</Label>
<div className="flex flex-wrap gap-2">
{(["auto", "extend", "primary", "exclusive"] as const).map((t) => (
<Button
key={t}
size="sm"
variant={topology === t ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, topology: t })}
>
{TOPOLOGY_LABEL[t]()}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="disp-max">{m.display_max()}</Label>
<Input
id="disp-max"
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)),
})
}
/>
</div>
</div>
)}
{/* Effective preview */}
{effective && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">{TOPOLOGY_LABEL[effective.topology]()}</Badge>
<Badge variant="outline">{effective.mode_conflict}</Badge>
<Badge variant="outline">{effective.identity}</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>
)}
<Button onClick={onSave} disabled={busy}>
{m.display_save()}
</Button>
</div>
);
};
+1 -7
View File
@@ -1,7 +1,6 @@
import type { FC } from "react";
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n";
import { DisplaySection } from "./DisplayCard";
import { GpuSection } from "./GpuCard";
import { HostView } from "./view";
@@ -11,11 +10,6 @@ export const SectionHost: FC = () => {
const compositors = useListCompositors();
return (
<HostView
host={host}
compositors={compositors}
gpu={<GpuSection />}
displays={<DisplaySection />}
/>
<HostView host={host} compositors={compositors} gpu={<GpuSection />} />
);
};
+1 -5
View File
@@ -13,9 +13,7 @@ export const HostView: FC<{
compositors: Loadable<AvailableCompositor[]>;
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
gpu?: ReactNode;
/** The virtual-display management card (self-contained container — see `DisplayCard.tsx`). */
displays?: ReactNode;
}> = ({ host, compositors, gpu, displays }) => {
}> = ({ host, compositors, gpu }) => {
const h = host.data;
return (
<Section maxWidth={false}>
@@ -83,8 +81,6 @@ export const HostView: FC<{
{gpu}
{displays}
<Card>
<CardHeader>
<CardTitle>{m.host_compositors()}</CardTitle>