Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c854893bc | ||
|
|
6b7997cace | ||
|
|
78ba2342b5 | ||
|
|
fd98406868 | ||
|
|
5c70a90358 | ||
|
|
9e598f8595 | ||
|
|
bd86598d97 | ||
|
|
e22082ac2a | ||
|
|
07f6d6f324 | ||
|
|
54666e66da | ||
|
|
5872dfc649 | ||
|
|
bed58b75b6 | ||
|
|
ce31a9ddfd | ||
|
|
8fe834c89b | ||
|
|
9e7713eecf | ||
|
|
0cd946acb5 | ||
|
|
62a6fa9fac | ||
|
|
d402e9b996 | ||
|
|
f23e0df64c | ||
|
|
12f39e1967 | ||
|
|
2a1c968a0e | ||
|
|
eb8c943572 | ||
|
|
102f550bba | ||
|
|
818531a26e | ||
|
|
608baf63be | ||
|
|
8f9c72877e | ||
|
|
8f32976349 | ||
|
|
deef5e4382 | ||
|
|
32cc8dd529 | ||
|
|
fba22c6c64 | ||
|
|
4b514cc07c | ||
|
|
f242b2d2fc | ||
|
|
30bd10e301 | ||
|
|
975fef2048 |
@@ -320,6 +320,46 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||||
|
|
||||||
|
# punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged
|
||||||
|
# build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a
|
||||||
|
# stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz.
|
||||||
|
#
|
||||||
|
# CACHED on packaging/gamescope/** alone — it depends on nothing else in this repo, so a
|
||||||
|
# normal push restores a binary instead of spending ~10 minutes on someone else's tree.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
id: gamescope
|
||||||
|
with:
|
||||||
|
path: gs-cache
|
||||||
|
key: punktfunk-gamescope-noble-${{ hashFiles('packaging/gamescope/**') }}
|
||||||
|
|
||||||
|
- name: Build the patched gamescope
|
||||||
|
if: steps.gamescope.outputs.cache-hit != 'true'
|
||||||
|
# Best-effort, exactly like rpm.yml: the host packages above are the primary delivery and
|
||||||
|
# work without this binary, so a hiccup building an unrelated tree must not fail the job.
|
||||||
|
# `build-dep gamescope` resolves the distro's much older packaged version, so it can come up
|
||||||
|
# short — that is what the `|| true`s absorb, and the marker check downstream is what makes
|
||||||
|
# a half-built result impossible to ship.
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends meson ninja-build glslc git || true
|
||||||
|
apt-get build-dep -y gamescope || true
|
||||||
|
if bash packaging/gamescope/build-punktfunk-gamescope.sh \
|
||||||
|
--destdir "$PWD/gs-stage" --prefix /usr --jobs "$(nproc)"; then
|
||||||
|
install -Dm0755 gs-stage/usr/bin/punktfunk-gamescope gs-cache/punktfunk-gamescope
|
||||||
|
else
|
||||||
|
echo "::warning::punktfunk-gamescope failed to build on noble — no .deb this run (gamescope sessions stay SDR)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build punktfunk-gamescope .deb
|
||||||
|
# Picked up by the publish loop below, which globs dist/*.deb.
|
||||||
|
run: |
|
||||||
|
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||||
|
bash packaging/debian/build-gamescope-deb.sh --binary gs-cache/punktfunk-gamescope
|
||||||
|
else
|
||||||
|
echo "::warning::no usable punktfunk-gamescope — skipping its .deb"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Publish to the Gitea apt registry
|
- name: Publish to the Gitea apt registry
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|||||||
@@ -213,6 +213,40 @@ jobs:
|
|||||||
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
echo "::warning::punktfunk-gamescope failed to build for f${{ matrix.fedver }} — the sysext ships without it (gamescope sessions stay SDR)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The same binary, as an ordinary RPM. The sysext below is the Atomic/Bazzite delivery; this
|
||||||
|
# is the one a traditional Fedora-family box (Nobara, plain Fedora) can actually install —
|
||||||
|
# until it existed those users had no packaged route to the patched build at all, and a stock
|
||||||
|
# gamescope tells every game its display is 60 Hz whatever the client negotiated.
|
||||||
|
#
|
||||||
|
# Same best-effort rule as the build above: no binary, no package, and the host stays on its
|
||||||
|
# existing SDR/host-composited path. The spec re-checks the +pfhdr marker itself.
|
||||||
|
- name: Package punktfunk-gamescope as an RPM
|
||||||
|
run: |
|
||||||
|
if [ -x gs-cache/punktfunk-gamescope ] && gs-cache/punktfunk-gamescope --version >/dev/null 2>&1; then
|
||||||
|
bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||||
|
--binary gs-cache/punktfunk-gamescope \
|
||||||
|
--release "$PF_RELEASE"
|
||||||
|
else
|
||||||
|
echo "::warning::no usable punktfunk-gamescope for f${{ matrix.fedver }} — skipping its RPM"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publish punktfunk-gamescope to the Gitea RPM registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
for rpm in dist/punktfunk-gamescope-*.rpm; do
|
||||||
|
case "$rpm" in *debuginfo*|*debugsource*) continue;; esac
|
||||||
|
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||||
|
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||||
|
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||||
|
echo "uploading $rpm"
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||||
|
done
|
||||||
|
|
||||||
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
|
||||||
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
|
||||||
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ capability rode on `input`, which every gamepad guide tells users to join — bu
|
|||||||
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
|
arbitrary USB hardware. Operators must `usermod -aG punktfunk "$USER"` and re-login or the pad stops
|
||||||
attaching. Ordinary virtual gamepads are unaffected.
|
attaching. Ordinary virtual gamepads are unaffected.
|
||||||
|
|
||||||
|
> **Known issue in 0.25.0, fixed after it.** Four of the six install paths shipped
|
||||||
|
> `60-punktfunk.rules` — whose `RUN+=` does `chgrp punktfunk` on the vhci `attach`/`detach` nodes —
|
||||||
|
> without ever creating the group, so the `chgrp` failed, the nodes stayed root-only, and the pad
|
||||||
|
> silently never attached. The `usermod` above also fails outright on those boxes with *group
|
||||||
|
> 'punktfunk' does not exist*. Affected: **Arch/CachyOS upgraded** rather than freshly installed
|
||||||
|
> (`post_upgrade` called only `_ensure_update_group`), the **NixOS module** (no
|
||||||
|
> `users.groups.punktfunk`), the **Bazzite sysext** (a group is host state and cannot ride an
|
||||||
|
> image), and **Steam Deck source installs** (`scripts/steamdeck/install.sh`/`update.sh` handled
|
||||||
|
> only `input`). The deb and rpm scriptlets were correct throughout — they run one `%post`/`postinst`
|
||||||
|
> on install and upgrade alike. All four now create the group, and the two that know which user
|
||||||
|
> runs the host (the Deck scripts and the NixOS module's `host.users`) add that user to it as well.
|
||||||
|
> Workaround on an unpatched box:
|
||||||
|
> `sudo groupadd --system punktfunk`, then the `usermod`, then re-login.
|
||||||
|
|
||||||
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
|
**3. Plugins may no longer set `launch.command` or the pre-launch command.** Both run through a
|
||||||
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
|
shell and are now operator-token only; a plugin that sets them is refused. Third-party plugins that
|
||||||
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
|
populated them need updating — use the `launcher_ui` / `xbox` launch kinds instead.
|
||||||
|
|||||||
@@ -69,11 +69,14 @@ fun App(forceGamepadUi: Boolean = false) {
|
|||||||
// later manual Back out of the library is not undone by a stale value.
|
// later manual Back out of the library is not undone by a stale value.
|
||||||
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
|
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
|
||||||
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
|
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
|
||||||
|
// connect/disconnect — unless the mode is Always, where it simply stays.
|
||||||
val tv = remember { isTvDevice(context) }
|
val tv = remember { isTvDevice(context) }
|
||||||
val controllerConnected by rememberControllerConnected()
|
val controllerConnected by rememberControllerConnected()
|
||||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
val gamepadUi = gamepadUiActive(
|
||||||
|
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||||
|
)
|
||||||
|
|
||||||
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
|
||||||
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class GamepadPalette(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
* The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||||
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
|
* ones. Cycling order runs dark → light, so stepping the row walks the range one way.
|
||||||
*/
|
*/
|
||||||
val ALL = listOf(
|
val ALL = listOf(
|
||||||
@@ -77,6 +77,22 @@ class GamepadPalette(
|
|||||||
ground = Triple(0.075, 0.060, 0.160),
|
ground = Triple(0.075, 0.060, 0.160),
|
||||||
accent = Triple(0.525, 0.471, 0.961), light = false,
|
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||||
),
|
),
|
||||||
|
GamepadPalette(
|
||||||
|
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no
|
||||||
|
// glow, no power. The first two stops are literally (0,0,0), so the shaded half
|
||||||
|
// of the field is genuinely off rather than "very dark grey", and the ground is
|
||||||
|
// pure black too: the calm mix on the form screens lifts toward nothing. What is
|
||||||
|
// left is a faint indigo→violet ember in the bright corner. The accent stays the
|
||||||
|
// brand violet — focus has to be findable on black.
|
||||||
|
"oled", "OLED",
|
||||||
|
listOf(
|
||||||
|
Triple(0.000, 0.000, 0.000), Triple(0.000, 0.000, 0.000),
|
||||||
|
Triple(0.010, 0.020, 0.100), Triple(0.045, 0.016, 0.115),
|
||||||
|
Triple(0.120, 0.024, 0.130),
|
||||||
|
),
|
||||||
|
ground = Triple(0.0, 0.0, 0.0),
|
||||||
|
accent = Triple(0.525, 0.471, 0.961), light = false,
|
||||||
|
),
|
||||||
GamepadPalette(
|
GamepadPalette(
|
||||||
// Deep indigo climbing through violet into a hot magenta.
|
// Deep indigo climbing through violet into a hot magenta.
|
||||||
"nebula", "Nebula",
|
"nebula", "Nebula",
|
||||||
|
|||||||
@@ -665,6 +665,21 @@ internal fun buildSettingsRows(
|
|||||||
"Turn off to use the touch interface even with a controller connected.",
|
"Turn off to use the touch interface even with a controller connected.",
|
||||||
s.gamepadUiEnabled,
|
s.gamepadUiEnabled,
|
||||||
) { update(s.copy(gamepadUiEnabled = it)) },
|
) { update(s.copy(gamepadUiEnabled = it)) },
|
||||||
|
) + listOfNotNull(
|
||||||
|
// WHEN the switch above takes over. Built only while it is ON: turn the switch off from
|
||||||
|
// this very screen and the row under the cursor would otherwise be one deciding nothing,
|
||||||
|
// on a screen that is itself about to disappear.
|
||||||
|
if (s.gamepadUiEnabled) {
|
||||||
|
choice(
|
||||||
|
"gamepadUIMode", GpTab.INTERFACE, null, "Show it",
|
||||||
|
"With a controller: the touch interface comes back when the last one " +
|
||||||
|
"disconnects. Always keeps this layout either way — for a device that lives " +
|
||||||
|
"docked to a TV. A TV itself is always in this mode regardless.",
|
||||||
|
GAMEPAD_UI_MODE_OPTIONS, s.gamepadUiMode,
|
||||||
|
) { update(s.copy(gamepadUiMode = it)) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,35 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Settings.gamepadUiMode]: take over only while a controller is attached. The default, and what
|
||||||
|
* the switch meant when it was a lone Boolean.
|
||||||
|
*/
|
||||||
|
const val GAMEPAD_UI_WHEN_CONNECTED = "connected"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Settings.gamepadUiMode]: take over whenever the switch is on, pad or no pad — for a phone or
|
||||||
|
* tablet that lives docked to a TV, where the console layout is the one wanted and the pad is not
|
||||||
|
* always awake.
|
||||||
|
*/
|
||||||
|
const val GAMEPAD_UI_ALWAYS = "always"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
|
||||||
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
|
||||||
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
|
* the user's [enabled] setting AND (the [mode] is [GAMEPAD_UI_ALWAYS] OR a controller is attached
|
||||||
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
|
* OR this is a TV OR the dev [forced] flag). A TV counts unconditionally — its remote/gamepad is
|
||||||
* console UI (as long as the setting is on).
|
* the only input, so it's always the console UI (as long as the setting is on), which is why the
|
||||||
|
* mode row means nothing there. An unrecognized [mode] waits for a controller, so a value a newer
|
||||||
|
* client wrote can never strand this one in a layout it has no way back out of.
|
||||||
*/
|
*/
|
||||||
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
|
fun gamepadUiActive(
|
||||||
enabled && (controllerConnected || tv || forced)
|
enabled: Boolean,
|
||||||
|
mode: String,
|
||||||
|
controllerConnected: Boolean,
|
||||||
|
tv: Boolean,
|
||||||
|
forced: Boolean,
|
||||||
|
): Boolean = enabled && (mode == GAMEPAD_UI_ALWAYS || controllerConnected || tv || forced)
|
||||||
|
|
||||||
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
|
||||||
fun isTvDevice(context: Context): Boolean {
|
fun isTvDevice(context: Context): Boolean {
|
||||||
|
|||||||
@@ -94,11 +94,20 @@ data class Settings(
|
|||||||
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
val touchMode: TouchMode = TouchMode.TRACKPAD,
|
||||||
/**
|
/**
|
||||||
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
|
||||||
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
|
* gamepad chrome) — mirrors the Apple client's `gamepadUIEnabled`. On by default; turn it off
|
||||||
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
|
* to keep the touch UI even with a pad attached. WHEN it takes over is [gamepadUiMode].
|
||||||
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
|
||||||
*/
|
*/
|
||||||
val gamepadUiEnabled: Boolean = true,
|
val gamepadUiEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* When [gamepadUiEnabled] actually takes over — the cross-client `gamepad_ui_mode` pair,
|
||||||
|
* mirroring the Apple client's `gamepadUIMode`: `"connected"` (default, and what the switch
|
||||||
|
* has always meant) waits for a controller; `"always"` keeps the console UI with no pad in
|
||||||
|
* reach, for a phone or tablet that lives docked to a TV. Read only while [gamepadUiEnabled]
|
||||||
|
* is on, which is why both settings screens hide the row when the switch is off. Anything
|
||||||
|
* unrecognized resolves to `"connected"`. A TV ignores it — it is always in console mode.
|
||||||
|
*/
|
||||||
|
val gamepadUiMode: String = GAMEPAD_UI_WHEN_CONNECTED,
|
||||||
/**
|
/**
|
||||||
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
|
||||||
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
|
||||||
@@ -107,9 +116,10 @@ data class Settings(
|
|||||||
val libraryEnabled: Boolean = true,
|
val libraryEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
* Which colour family the console (gamepad) UI's living backdrop drifts through — the
|
||||||
* cross-client `ui_palette` key: `"violet"` (the brand default), `"tide"`, `"forest"`,
|
* cross-client `ui_palette` key: `"violet"` (the brand default), then `"oled"`, `"nebula"`,
|
||||||
* `"ember"`, `"rose"`, `"graphite"`. See [GamepadPalette], whose table and maths mirror the
|
* `"abyss"`, `"ember"`, `"moss"`, `"graphite"`, then the six pale fields. See
|
||||||
* desktop console's and the Apple client's under the same names. Presentation only: nothing
|
* [GamepadPalette], whose table and maths mirror the desktop console's and the Apple
|
||||||
|
* client's under the same names. Presentation only: nothing
|
||||||
* about a stream depends on it, so it is a device preference and never part of a profile.
|
* about a stream depends on it, so it is a device preference and never part of a profile.
|
||||||
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
* An unknown value reads as the default rather than failing — a newer client may have shipped
|
||||||
* a palette this build doesn't know.
|
* a palette this build doesn't know.
|
||||||
@@ -303,6 +313,8 @@ class SettingsStore(context: Context) {
|
|||||||
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
|
||||||
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
|
||||||
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
|
||||||
|
gamepadUiMode = prefs.getString(K_GAMEPAD_UI_MODE, GAMEPAD_UI_WHEN_CONNECTED)
|
||||||
|
?: GAMEPAD_UI_WHEN_CONNECTED,
|
||||||
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
|
||||||
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
uiPalette = prefs.getString(K_UI_PALETTE, "violet") ?: "violet",
|
||||||
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
|
||||||
@@ -344,6 +356,7 @@ class SettingsStore(context: Context) {
|
|||||||
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
|
||||||
.putString(K_TOUCH_MODE, s.touchMode.name)
|
.putString(K_TOUCH_MODE, s.touchMode.name)
|
||||||
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
|
||||||
|
.putString(K_GAMEPAD_UI_MODE, s.gamepadUiMode)
|
||||||
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
.putBoolean(K_LIBRARY, s.libraryEnabled)
|
||||||
.putString(K_UI_PALETTE, s.uiPalette)
|
.putString(K_UI_PALETTE, s.uiPalette)
|
||||||
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
|
||||||
@@ -384,6 +397,7 @@ class SettingsStore(context: Context) {
|
|||||||
const val K_HUD = "stats_hud_enabled"
|
const val K_HUD = "stats_hud_enabled"
|
||||||
const val K_TOUCH_MODE = "touch_mode"
|
const val K_TOUCH_MODE = "touch_mode"
|
||||||
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
|
||||||
|
const val K_GAMEPAD_UI_MODE = "gamepad_ui_mode"
|
||||||
const val K_LIBRARY = "library_enabled"
|
const val K_LIBRARY = "library_enabled"
|
||||||
const val K_UI_PALETTE = "ui_palette"
|
const val K_UI_PALETTE = "ui_palette"
|
||||||
|
|
||||||
@@ -778,6 +792,13 @@ fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** (stored value, label) for when the console UI takes over — the Apple client's table verbatim.
|
||||||
|
* Only offered while [Settings.gamepadUiEnabled] is on; a TV is in console mode either way. */
|
||||||
|
val GAMEPAD_UI_MODE_OPTIONS = listOf(
|
||||||
|
GAMEPAD_UI_WHEN_CONNECTED to "With a controller",
|
||||||
|
GAMEPAD_UI_ALWAYS to "Always",
|
||||||
|
)
|
||||||
|
|
||||||
/** (mode, label) for the touch-input model. */
|
/** (mode, label) for the touch-input model. */
|
||||||
val TOUCH_MODE_OPTIONS = listOf(
|
val TOUCH_MODE_OPTIONS = listOf(
|
||||||
TouchMode.TRACKPAD to "Trackpad",
|
TouchMode.TRACKPAD to "Trackpad",
|
||||||
|
|||||||
@@ -592,11 +592,24 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) {
|
|||||||
SettingsGroup("Interface") {
|
SettingsGroup("Interface") {
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
title = "Controller-optimized UI",
|
title = "Controller-optimized UI",
|
||||||
subtitle = "Switch to the console home when a controller is connected. A TV " +
|
subtitle = "Swap the touch home for the console home — the host carousel and " +
|
||||||
"always uses it.",
|
"gamepad chrome. A TV always uses it.",
|
||||||
checked = s.gamepadUiEnabled,
|
checked = s.gamepadUiEnabled,
|
||||||
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
|
||||||
)
|
)
|
||||||
|
// Only decides anything while the switch above is on, so it is HIDDEN rather than
|
||||||
|
// dimmed when it isn't — a picker whose every option changes nothing is worse than
|
||||||
|
// no picker, and this group is short enough that nothing jumps far.
|
||||||
|
if (s.gamepadUiEnabled) {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Show it",
|
||||||
|
options = GAMEPAD_UI_MODE_OPTIONS,
|
||||||
|
selected = s.gamepadUiMode,
|
||||||
|
caption = "With a controller: the touch home comes back when the last one " +
|
||||||
|
"disconnects. Always keeps the console home either way — for a device " +
|
||||||
|
"that lives docked to a TV.",
|
||||||
|
) { v -> update(s.copy(gamepadUiMode = v)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ class GamepadPaletteTest {
|
|||||||
fun tableMatchesTheOtherClients() {
|
fun tableMatchesTheOtherClients() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
"violet", "nebula", "abyss", "ember", "moss", "graphite",
|
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||||
"holo", "sunset", "bloom", "dawn", "mint", "opal",
|
"holo", "sunset", "bloom", "dawn", "mint", "opal",
|
||||||
),
|
),
|
||||||
GamepadPalette.ALL.map { it.id },
|
GamepadPalette.ALL.map { it.id },
|
||||||
)
|
)
|
||||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||||
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
|
val firstLight = GamepadPalette.ALL.indexOfFirst { it.light }
|
||||||
assertEquals(6, firstLight)
|
assertEquals(7, firstLight)
|
||||||
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
|
assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light })
|
||||||
// An unknown name is a newer client's palette, not an error.
|
// An unknown name is a newer client's palette, not an error.
|
||||||
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
assertEquals("violet", GamepadPalette.named("chartreuse").id)
|
||||||
@@ -72,6 +72,25 @@ class GamepadPaletteTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||||
|
* not merely the darkest of the dark fields. The blob field this client draws samples the
|
||||||
|
* ramp at 0.15/0.40/0.65/0.90, so its darkest blob lands in the all-black head of the ramp.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun oledIsActuallyBlack() {
|
||||||
|
val oled = GamepadPalette.named("oled")
|
||||||
|
assertEquals(Triple(0.0, 0.0, 0.0), oled.ground)
|
||||||
|
assertEquals(0f, oled.blobColors[0].red, 1e-6f)
|
||||||
|
assertEquals(0f, oled.blobColors[0].green, 1e-6f)
|
||||||
|
assertEquals(0f, oled.blobColors[0].blue, 1e-6f)
|
||||||
|
val mean = oled.stops.sumOf { luma(it) } / oled.stops.size
|
||||||
|
val darkestOther = GamepadPalette.ALL
|
||||||
|
.filter { it.id != "oled" && it.stops.isNotEmpty() }
|
||||||
|
.minOf { p -> p.stops.sumOf { luma(it) } / p.stops.size }
|
||||||
|
assertTrue("oled means $mean, barely under $darkestOther", mean < darkestOther / 2)
|
||||||
|
}
|
||||||
|
|
||||||
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
/** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */
|
||||||
@Test
|
@Test
|
||||||
fun palettesAreHonestAboutLightness() {
|
fun palettesAreHonestAboutLightness() {
|
||||||
|
|||||||
@@ -95,4 +95,47 @@ class GamepadSettingsRowsTest {
|
|||||||
// Drawn as a switch, and reading the persisted default.
|
// Drawn as a switch, and reading the persisted default.
|
||||||
assertEquals(true, row(on, "dsCapture").toggled)
|
assertEquals(true, row(on, "dsCapture").toggled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
|
||||||
|
* OFFERED only while that switch is on — hidden rather than dimmed, because with the switch
|
||||||
|
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
|
||||||
|
* be one last thing to step past on the way out.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the activation-mode row follows the switch it belongs to`() {
|
||||||
|
fun ids(enabled: Boolean) = buildSettingsRows(
|
||||||
|
Settings(gamepadUiEnabled = enabled),
|
||||||
|
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||||
|
) {}.map { it.id }
|
||||||
|
|
||||||
|
val on = ids(enabled = true)
|
||||||
|
assertTrue("the mode row is missing", "gamepadUIMode" in on)
|
||||||
|
assertEquals(
|
||||||
|
"the mode belongs directly under the switch it qualifies",
|
||||||
|
on.indexOf("gamepadUI") + 1,
|
||||||
|
on.indexOf("gamepadUIMode"),
|
||||||
|
)
|
||||||
|
val off = ids(enabled = false)
|
||||||
|
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
|
||||||
|
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
|
||||||
|
@Test
|
||||||
|
fun `the activation-mode row steps the shared key`() {
|
||||||
|
var s = Settings()
|
||||||
|
fun mode() = buildSettingsRows(
|
||||||
|
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
|
||||||
|
) { s = it }.first { it.id == "gamepadUIMode" }
|
||||||
|
|
||||||
|
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||||
|
assertEquals("With a controller", mode().value)
|
||||||
|
assertFalse("already the first = thud", mode().adjust(-1))
|
||||||
|
assertTrue(mode().adjust(1))
|
||||||
|
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
|
||||||
|
// A from the last entry wraps home.
|
||||||
|
mode().activate()
|
||||||
|
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package io.unom.punktfunk
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [gamepadUiActive] is pure — table-tested over its inputs, and the mirror of the Apple client's
|
||||||
|
* `GamepadUIEnvironmentTests`. The two clients share the stored `gamepad_ui_mode` values, so a
|
||||||
|
* disagreement here is a device that behaves differently from the same setting.
|
||||||
|
*/
|
||||||
|
class GamepadUiTest {
|
||||||
|
|
||||||
|
/** The default mode is what the switch meant when it was a lone Boolean. */
|
||||||
|
@Test
|
||||||
|
fun whenConnectedWaitsForAPad() {
|
||||||
|
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||||
|
assertFalse(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||||
|
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, true, tv = false, forced = false))
|
||||||
|
assertFalse(gamepadUiActive(false, GAMEPAD_UI_WHEN_CONNECTED, false, tv = false, forced = false))
|
||||||
|
// A TV is in console mode whatever the mode says — its remote is the only input.
|
||||||
|
assertTrue(gamepadUiActive(true, GAMEPAD_UI_WHEN_CONNECTED, false, tv = true, forced = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always drops the controller from the decision — but never the switch, which is the one
|
||||||
|
* way back to the touch UI. */
|
||||||
|
@Test
|
||||||
|
fun alwaysIgnoresThePadButNotTheSwitch() {
|
||||||
|
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||||
|
assertTrue(gamepadUiActive(true, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||||
|
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, false, tv = false, forced = false))
|
||||||
|
assertFalse(gamepadUiActive(false, GAMEPAD_UI_ALWAYS, true, tv = false, forced = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A value a newer client wrote waits for a pad rather than stranding this build in a
|
||||||
|
* layout it has no way back out of. */
|
||||||
|
@Test
|
||||||
|
fun anUnknownModeWaitsForAPad() {
|
||||||
|
assertFalse(gamepadUiActive(true, "whenever-i-say-so", false, tv = false, forced = false))
|
||||||
|
assertTrue(gamepadUiActive(true, "whenever-i-say-so", true, tv = false, forced = false))
|
||||||
|
assertFalse(gamepadUiActive(true, "", false, tv = false, forced = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shipped default: the console UI still waits for a controller. */
|
||||||
|
@Test
|
||||||
|
fun theDefaultIsUnchangedBehaviour() {
|
||||||
|
val s = Settings()
|
||||||
|
assertTrue(s.gamepadUiEnabled)
|
||||||
|
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
|
||||||
|
assertFalse(gamepadUiActive(s.gamepadUiEnabled, s.gamepadUiMode, false, tv = false, forced = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ class ProfilesTest {
|
|||||||
|
|
||||||
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
// Device-scope settings are not in the overlay at all, so no profile can move them.
|
||||||
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
assertEquals(base.gamepadUiEnabled, out.gamepadUiEnabled)
|
||||||
|
assertEquals(base.gamepadUiMode, out.gamepadUiMode)
|
||||||
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
assertEquals(base.libraryEnabled, out.libraryEnabled)
|
||||||
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
assertEquals(base.autoWakeEnabled, out.autoWakeEnabled)
|
||||||
assertEquals(base.sc2Capture, out.sc2Capture)
|
assertEquals(base.sc2Capture, out.sc2Capture)
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ struct ContentView: View {
|
|||||||
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
/// When the switch above takes over — "connected" (default) or "always". See
|
||||||
|
/// `GamepadUIEnvironment`.
|
||||||
|
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||||
|
GamepadUIEnvironment.modeWhenConnected
|
||||||
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
/// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host
|
||||||
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
/// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects
|
||||||
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
/// go straight through with no wake. The explicit "Wake Host" action is unaffected either way.
|
||||||
@@ -113,7 +117,8 @@ struct ContentView: View {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||||
|
mode: gamepadUIMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and
|
// The body is split in two — `driven` (the screen plus its lifecycle drivers and sheets) and
|
||||||
@@ -901,6 +906,7 @@ struct ContentView: View {
|
|||||||
private var shortcutHintText: String {
|
private var shortcutHintText: String {
|
||||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||||
|
+ " · Hold Play/Pause, or Select+X on a controller, for statistics"
|
||||||
}
|
}
|
||||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -85,16 +85,40 @@ extension EnvironmentValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
/// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the
|
/// Resolve the stored `ui_palette` and publish its ink — AND the matching colour scheme — to
|
||||||
/// gamepad screens' common root so no individual view has to read the setting.
|
/// everything below. Applied by the gamepad screens' common root so no individual view has to
|
||||||
func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) }
|
/// read the setting.
|
||||||
|
///
|
||||||
|
/// `active` exists for the one surface that is the same view in both worlds: `LibraryView`
|
||||||
|
/// renders the coverflow under the gamepad UI and a plain grid without it. Passing `false`
|
||||||
|
/// publishes nothing, because the touch/desktop layouts sit on the SYSTEM background, where a
|
||||||
|
/// palette's scheme would invert their own system colours instead of matching them.
|
||||||
|
func gamepadPaletteInk(_ active: Bool = true) -> some View {
|
||||||
|
modifier(GamepadInkModifier(active: active))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GamepadInkModifier: ViewModifier {
|
private struct GamepadInkModifier: ViewModifier {
|
||||||
|
var active = true
|
||||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||||
|
/// The ambient scheme from ABOVE this modifier — what gets republished unchanged when the
|
||||||
|
/// gamepad UI isn't the one drawing, so `active: false` is a true no-op rather than a branch
|
||||||
|
/// that would change this view's identity.
|
||||||
|
@Environment(\.colorScheme) private var systemScheme
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID)))
|
let palette = GamepadPalette.named(paletteID)
|
||||||
|
return content
|
||||||
|
.environment(\.gamepadInk, active ? GamepadInk.of(palette) : .dark)
|
||||||
|
// The ink alone was never enough. Every SYSTEM-derived colour that lands on these
|
||||||
|
// screens — `.secondary` in a placeholder, a `.bordered` button's chrome, a
|
||||||
|
// NavigationStack's title, a material's frost — resolves against the DEVICE's
|
||||||
|
// appearance, which no part of this app had ever set. On iPhone and Mac that is often
|
||||||
|
// Light, so the pale palettes looked correct by accident; an Apple TV is Dark
|
||||||
|
// essentially always, so on tvOS every one of them came out WHITE on a pale field and
|
||||||
|
// the interface was unreadable. Publishing the scheme here — once, beside the ink it
|
||||||
|
// has to agree with — is what makes a pale palette mean "light" to UIKit too.
|
||||||
|
.environment(\.colorScheme, active ? (palette.light ? .light : .dark) : systemScheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ struct LibraryView: View {
|
|||||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||||
|
GamepadUIEnvironment.modeWhenConnected
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
|
||||||
|
mode: gamepadUIMode)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -78,6 +81,16 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
|
// Published HERE, not just inside the coverflow, because the coverflow is only one of
|
||||||
|
// four things this view renders: the loading spinner, the error state and the empty
|
||||||
|
// state sit above it, as do the navigation title and toolbar. On iOS those are wrapped
|
||||||
|
// by GamepadLibraryScreen, which inks the whole thing; tvOS and macOS present this view
|
||||||
|
// directly in a NavigationStack, so under a pale palette every one of them kept the
|
||||||
|
// system's own (dark, on an Apple TV) chrome over a light field. Off when the gamepad
|
||||||
|
// UI isn't drawing — the plain grid belongs to the system background.
|
||||||
|
.gamepadPaletteInk(gamepadUIActive)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var content: some View {
|
@ViewBuilder private var content: some View {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
/// When the switch above takes over — the row is only built while it is on.
|
||||||
|
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||||
|
GamepadUIEnvironment.modeWhenConnected
|
||||||
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
/// The gamepad UI's background colour family — the backdrop BEHIND this screen re-colours as
|
||||||
/// the row steps, which is why the picker lives here and not in a sheet.
|
/// the row steps, which is why the picker lives here and not in a sheet.
|
||||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||||
@@ -659,6 +662,21 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||||
value: $gamepadUIEnabled),
|
value: $gamepadUIEnabled),
|
||||||
]
|
]
|
||||||
|
// WHEN the switch above takes over. Built only while it is on: with the switch off this
|
||||||
|
// screen is unreachable in the first place (no gamepad UI to open it from), so a row
|
||||||
|
// that decides nothing would exist purely to be found in a screenshot.
|
||||||
|
if gamepadUIEnabled, let at = list.firstIndex(where: { $0.id == "gamepadUI" }) {
|
||||||
|
list.insert(
|
||||||
|
choiceRow(
|
||||||
|
id: "gamepadUIMode", tab: .interface, icon: "gamecontroller.circle",
|
||||||
|
label: "Show it",
|
||||||
|
detail: "With a controller: the touch interface comes back when the last one "
|
||||||
|
+ "disconnects. Always keeps this layout either way — for a device that "
|
||||||
|
+ "lives on a TV.",
|
||||||
|
options: SettingsOptions.gamepadUIModes, current: gamepadUIMode
|
||||||
|
) { gamepadUIMode = $0 },
|
||||||
|
at: at + 1)
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||||
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
// the Video tab) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||||
@@ -707,6 +725,14 @@ struct GamepadSettingsView: View {
|
|||||||
at: anchor + 1)
|
at: anchor + 1)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
// The smoothness buffer only decides anything under Smoothness. Every other settings
|
||||||
|
// surface — touch, tvOS, the GTK and WinUI shells — hides it under Lowest latency; this
|
||||||
|
// screen alone left it live and steppable, which is a row that thuds or silently stores
|
||||||
|
// a value nothing reads. Removed here rather than omitted from the literal above so the
|
||||||
|
// macOS safe-present insertion can still anchor on it.
|
||||||
|
if presentPriority != "smooth" {
|
||||||
|
list.removeAll { $0.id == "smoothBuffer" }
|
||||||
|
}
|
||||||
return list + profileRows
|
return list + profileRows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
|
/// When the gamepad UI takes over (`DefaultsKey.gamepadUIMode`) — only meaningful while
|
||||||
|
/// `gamepadUIEnabled` is on, so every surface that offers it hides the row when the switch
|
||||||
|
/// is off rather than showing a picker that decides nothing.
|
||||||
|
static let gamepadUIModes: [(label: String, tag: String)] = [
|
||||||
|
("With a controller", GamepadUIEnvironment.modeWhenConnected),
|
||||||
|
("Always", GamepadUIEnvironment.modeAlways),
|
||||||
|
]
|
||||||
|
|
||||||
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
/// Presentation intent (`DefaultsKey.presentPriority` — the 2026-07 rebuild that replaced
|
||||||
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
/// the visible stage picker with intent; see SessionPresenter's PresentPriority and
|
||||||
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
/// design/apple-presentation-rebuild.md). The stage ladder survives only as the hidden
|
||||||
|
|||||||
@@ -724,11 +724,24 @@ extension SettingsView {
|
|||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
if !inProfileScope {
|
if !inProfileScope {
|
||||||
described("With a controller connected, the host list and library switch to a "
|
described("The host list and library switch to a controller-friendly layout — "
|
||||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
+ "larger focus targets, a swipeable cover browser.") {
|
||||||
+ "browser.") {
|
|
||||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||||
}
|
}
|
||||||
|
// Only meaningful while the switch above is on, so it is HIDDEN rather than
|
||||||
|
// disabled when it isn't: a picker whose every option decides nothing is worse
|
||||||
|
// than no picker, and this Section is short enough that nothing jumps far.
|
||||||
|
if gamepadUIEnabled {
|
||||||
|
described("With a controller: the touch interface comes back when the last "
|
||||||
|
+ "one disconnects. Always keeps the controller-friendly layout either "
|
||||||
|
+ "way — for a device that lives on a TV.") {
|
||||||
|
Picker("Show it", selection: $gamepadUIMode) {
|
||||||
|
ForEach(SettingsOptions.gamepadUIModes, id: \.tag) { option in
|
||||||
|
Text(option.label).tag(option.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||||
|
/// When the switch above takes over — read (and shown) only while it is on.
|
||||||
|
@AppStorage(DefaultsKey.gamepadUIMode) var gamepadUIMode =
|
||||||
|
GamepadUIEnvironment.modeWhenConnected
|
||||||
|
/// The gamepad UI's background palette. Edited here on tvOS only (see `tvBody`) — every other
|
||||||
|
/// platform reaches it through the gamepad settings screen, which an Apple TV without a
|
||||||
|
/// controller cannot open.
|
||||||
|
@AppStorage(DefaultsKey.uiPalette) var uiPalette = "violet"
|
||||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||||
@@ -488,6 +495,22 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Gamepad-optimized browsing",
|
title: "Gamepad-optimized browsing",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||||
|
// Hidden while the switch above is off — see the touch settings' identical gate.
|
||||||
|
if gamepadUIEnabled {
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Show it",
|
||||||
|
options: SettingsOptions.gamepadUIModes, selection: $gamepadUIMode)
|
||||||
|
// The Apple TV's ONLY route to the shared `ui_palette`. Everywhere else the
|
||||||
|
// Background row lives on the gamepad settings screen, which is reached from
|
||||||
|
// the gamepad launcher — and on tvOS that launcher needs an extended-profile
|
||||||
|
// controller, so an Apple TV driven by the Siri Remote alone could not reach
|
||||||
|
// the palettes at all. It belongs beside "Show it" because both describe the
|
||||||
|
// same interface: this row is what that interface looks like once it is up.
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Background",
|
||||||
|
options: GamepadPalette.all.map { (label: $0.name, tag: $0.id) },
|
||||||
|
selection: $uiPalette)
|
||||||
|
}
|
||||||
tvCaption(Self.controllersFooter)
|
tvCaption(Self.controllersFooter)
|
||||||
NavigationLink("About") { AboutView() }
|
NavigationLink("About") { AboutView() }
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|||||||
@@ -95,24 +95,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
|
// The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`.
|
||||||
|
// Scoped to the fill it frosts the material correctly and stops there, so a system colour
|
||||||
|
// in the row's own content (a `.secondary` label, a `.bordered` button) still resolved
|
||||||
|
// against the device appearance — which is how the pale palettes came out light-on-light
|
||||||
|
// on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the
|
||||||
|
// tvOS and pre-26 branches were the odd ones out.
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||||
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||||
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||||
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
// the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost.
|
||||||
content.background {
|
content
|
||||||
shape.fill(.ultraThinMaterial)
|
.background {
|
||||||
.environment(\.colorScheme, scheme)
|
|
||||||
.overlay { shape.fill(materialWash) }
|
|
||||||
.overlay {
|
|
||||||
if let tint { shape.fill(tint) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
if #available(iOS 26, macOS 26, *) {
|
|
||||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
|
||||||
} else {
|
|
||||||
content.background {
|
|
||||||
shape.fill(.ultraThinMaterial)
|
shape.fill(.ultraThinMaterial)
|
||||||
.environment(\.colorScheme, scheme)
|
.environment(\.colorScheme, scheme)
|
||||||
.overlay { shape.fill(materialWash) }
|
.overlay { shape.fill(materialWash) }
|
||||||
@@ -120,6 +115,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
if let tint { shape.fill(tint) }
|
if let tint { shape.fill(tint) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.environment(\.colorScheme, scheme)
|
||||||
|
#else
|
||||||
|
if #available(iOS 26, macOS 26, *) {
|
||||||
|
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.background {
|
||||||
|
shape.fill(.ultraThinMaterial)
|
||||||
|
.environment(\.colorScheme, scheme)
|
||||||
|
.overlay { shape.fill(materialWash) }
|
||||||
|
.overlay {
|
||||||
|
if let tint { shape.fill(tint) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, scheme)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -173,11 +183,14 @@ private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
|
|||||||
in: shape)
|
in: shape)
|
||||||
.environment(\.colorScheme, scheme)
|
.environment(\.colorScheme, scheme)
|
||||||
} else {
|
} else {
|
||||||
content.background {
|
// Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost.
|
||||||
shape.fill(.regularMaterial)
|
content
|
||||||
.environment(\.colorScheme, scheme)
|
.background {
|
||||||
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
shape.fill(.regularMaterial)
|
||||||
}
|
.environment(\.colorScheme, scheme)
|
||||||
|
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, scheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ public final class SessionAudio {
|
|||||||
/// session's activate.
|
/// session's activate.
|
||||||
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session")
|
||||||
#endif
|
#endif
|
||||||
|
#if os(iOS)
|
||||||
|
/// Live only for a `.playAndRecord` session: the token for the route-change observer that
|
||||||
|
/// keeps the BUILT-IN output on the speaker rather than the earpiece (see
|
||||||
|
/// `steerBuiltInOutputToSpeaker`). A `.playback` session already prefers the speaker and
|
||||||
|
/// never needs steering, so the mic-off path installs nothing. Guarded by `stateLock`.
|
||||||
|
private var routeObserver: NSObjectProtocol?
|
||||||
|
#endif
|
||||||
|
|
||||||
public init(connection: PunktfunkConnection) {
|
public init(connection: PunktfunkConnection) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -89,6 +96,11 @@ public final class SessionAudio {
|
|||||||
/// Engine teardown still belongs to stop().
|
/// Engine teardown still belongs to stop().
|
||||||
deinit {
|
deinit {
|
||||||
flag.stop()
|
flag.stop()
|
||||||
|
#if os(iOS)
|
||||||
|
// The observer only holds self weakly, so we can be deinited with it still registered;
|
||||||
|
// drop the token here too rather than leaking it when an owner skips stop().
|
||||||
|
if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
/// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default
|
||||||
@@ -138,11 +150,29 @@ public final class SessionAudio {
|
|||||||
do {
|
do {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if micEnabled {
|
if micEnabled {
|
||||||
// .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only
|
// NO .defaultToSpeaker here, deliberately. It reads like "prefer the speaker over
|
||||||
// affects the built-in route (headphones/BT still win).
|
// the earpiece", and the comment that used to sit here claimed headphones and
|
||||||
|
// Bluetooth still won. That is true of WIRED headphones and false of Bluetooth —
|
||||||
|
// a cable is the one way to test this and see the right answer. It is an
|
||||||
|
// OVERRIDE, and it outranks an A2DP route: with it set, every Bluetooth headset
|
||||||
|
// lost the stream to the phone's own speaker. That is the 0.25 field report ("no
|
||||||
|
// audio over Bluetooth ... plays through speakers if Mic input is enabled") — mic
|
||||||
|
// and echo cancellation both default to ON, so this branch is the DEFAULT path
|
||||||
|
// and every Bluetooth listener hit it; turning the mic off was the accidental
|
||||||
|
// workaround, because that lands on `.playback` below, which routes to A2DP
|
||||||
|
// happily.
|
||||||
|
//
|
||||||
|
// The earpiece problem it was reaching for is real, so it is solved after
|
||||||
|
// activation instead, against the route we were ACTUALLY given —
|
||||||
|
// see `steerBuiltInOutputToSpeaker`.
|
||||||
|
//
|
||||||
|
// `.allowBluetoothA2DP` alone, also deliberately: adding `.allowBluetooth` would
|
||||||
|
// make a headset's MIC usable, but it buys that by dragging the whole route onto
|
||||||
|
// HFP/SCO and collapsing game audio to narrowband. High-quality A2DP output plus
|
||||||
|
// the built-in mic is the better trade for a game-streaming client.
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
.playAndRecord, mode: .default,
|
.playAndRecord, mode: .default,
|
||||||
options: [.allowBluetoothA2DP, .defaultToSpeaker])
|
options: [.allowBluetoothA2DP])
|
||||||
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
// Uplink latency: ask for 5 ms IO quanta at the wire rate (the default ~10-23 ms
|
||||||
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
// quantum is most of the mic path's burst latency). Best-effort — the hardware
|
||||||
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
// has the final word (a Bluetooth route will ignore both), and whatever quantum
|
||||||
@@ -156,12 +186,66 @@ public final class SessionAudio {
|
|||||||
try session.setCategory(.playback, mode: .default)
|
try session.setCategory(.playback, mode: .default)
|
||||||
#endif
|
#endif
|
||||||
try session.setActive(true)
|
try session.setActive(true)
|
||||||
|
#if os(iOS)
|
||||||
|
// Only the `.playAndRecord` session can land on the earpiece, and only it accepts an
|
||||||
|
// output override — so the mic-off (`.playback`) path deliberately does neither.
|
||||||
|
if micEnabled {
|
||||||
|
steerBuiltInOutputToSpeaker(session)
|
||||||
|
installRouteObserver()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
/// `.playAndRecord` parks the BUILT-IN output on the earpiece — right for a phone call,
|
||||||
|
/// useless for a game. Move it to the speaker, but ONLY when the route we were actually given
|
||||||
|
/// is the receiver: anything external (Bluetooth, wired, CarPlay, AirPlay) is left strictly
|
||||||
|
/// alone. That "look first" is the whole difference between this and the `.defaultToSpeaker`
|
||||||
|
/// option it replaced, which forced the speaker unconditionally and so beat Bluetooth.
|
||||||
|
///
|
||||||
|
/// Idempotent and cheap, so the route observer can simply call it again.
|
||||||
|
private func steerBuiltInOutputToSpeaker(_ session: AVAudioSession) {
|
||||||
|
// An override already in force shows up as `.builtInSpeaker`, not `.builtInReceiver`, so
|
||||||
|
// re-running this never fights its own previous result.
|
||||||
|
guard session.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver })
|
||||||
|
else { return }
|
||||||
|
do {
|
||||||
|
try session.overrideOutputAudioPort(.speaker)
|
||||||
|
} catch {
|
||||||
|
log.warning("could not move audio off the earpiece: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes change under a live session: a headset connects mid-stream, or disconnects and hands
|
||||||
|
/// the stream back to the built-in output. iOS drops an output override whenever the route
|
||||||
|
/// changes — which is what lets a newly-connected headset win — so the earpiece steer is a
|
||||||
|
/// property of the CURRENT route and has to be re-applied per route. Without this, dropping
|
||||||
|
/// Bluetooth mid-stream would land the game on the earpiece.
|
||||||
|
private func installRouteObserver() {
|
||||||
|
let observer = NotificationCenter.default.addObserver(
|
||||||
|
forName: AVAudioSession.routeChangeNotification,
|
||||||
|
object: AVAudioSession.sharedInstance(), queue: nil
|
||||||
|
) { [weak self] _ in
|
||||||
|
// Arrives on whatever thread AVFoundation posts it from, and the session API blocks
|
||||||
|
// on the audio server — so do the work on the shared session queue, like every
|
||||||
|
// other call into it.
|
||||||
|
SessionAudio.sessionQueue.async {
|
||||||
|
guard let self, !self.flag.isStopped else { return }
|
||||||
|
self.steerBuiltInOutputToSpeaker(AVAudioSession.sharedInstance())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stateLock.lock()
|
||||||
|
let stale = routeObserver
|
||||||
|
routeObserver = observer
|
||||||
|
stateLock.unlock()
|
||||||
|
if let stale { NotificationCenter.default.removeObserver(stale) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
/// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` —
|
||||||
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
/// with the mic uplink only when enabled + authorized. Main thread (engine setup); on
|
||||||
/// iOS/tvOS the session is already active by the time this runs.
|
/// iOS/tvOS the session is already active by the time this runs.
|
||||||
@@ -249,7 +333,16 @@ public final class SessionAudio {
|
|||||||
combinedEngine = nil
|
combinedEngine = nil
|
||||||
let wasDraining = drainStarted
|
let wasDraining = drainStarted
|
||||||
drainStarted = false
|
drainStarted = false
|
||||||
|
#if os(iOS)
|
||||||
|
let route = routeObserver
|
||||||
|
routeObserver = nil
|
||||||
|
#endif
|
||||||
stateLock.unlock()
|
stateLock.unlock()
|
||||||
|
#if os(iOS)
|
||||||
|
// Before the deactivate below, so a route change during teardown can't re-steer a session
|
||||||
|
// we are in the middle of releasing.
|
||||||
|
if let route { NotificationCenter.default.removeObserver(route) }
|
||||||
|
#endif
|
||||||
if let capture {
|
if let capture {
|
||||||
capture.inputNode.removeTap(onBus: 0)
|
capture.inputNode.removeTap(onBus: 0)
|
||||||
capture.stop()
|
capture.stop()
|
||||||
|
|||||||
@@ -112,6 +112,24 @@ public final class GamepadCapture {
|
|||||||
static let escapeChordElements = [
|
static let escapeChordElements = [
|
||||||
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
|
GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions,
|
||||||
]
|
]
|
||||||
|
/// The stats-overlay chord: Select + X, one tier per completion (off → compact → normal →
|
||||||
|
/// detailed → off). It exists because a controller in both hands has no other way to the
|
||||||
|
/// numbers — the ⌃⌥⇧S combo needs a keyboard and the three-finger tap needs a free screen —
|
||||||
|
/// and on tvOS there is no other way AT ALL, which is what this fixes.
|
||||||
|
///
|
||||||
|
/// Built like Android's mic chord (`GamepadRouter.MIC_CHORD`, Select + Y) and deliberately
|
||||||
|
/// not overlapping `escapeChord`: X is none of its four buttons, so no way of reaching the
|
||||||
|
/// exit chord passes through this one on the way, and vice versa. Select is a menu button
|
||||||
|
/// rather than a twitch action, which keeps the pair out of real play. Y is left free so the
|
||||||
|
/// mic chord can be ported onto it later without moving this one.
|
||||||
|
static let statsChord: UInt32 = GamepadWire.back | GamepadWire.x
|
||||||
|
/// `statsChord`'s elements by GameController alias — same mirror-the-mask rule (and same
|
||||||
|
/// invisible failure) as `escapeChordElements`; the same test pins both.
|
||||||
|
static let statsChordElements = [GCInputButtonOptions, GCInputButtonX]
|
||||||
|
/// Every element some chord reads — what a NON-forwarding slot claims (see `openSlot`). The
|
||||||
|
/// escape chord's four plus the stats chord's X; Select is shared, so it appears once.
|
||||||
|
static let chordElements: [String] =
|
||||||
|
escapeChordElements + statsChordElements.filter { !escapeChordElements.contains($0) }
|
||||||
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
/// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client.
|
||||||
private static let disconnectHold: TimeInterval = 1.5
|
private static let disconnectHold: TimeInterval = 1.5
|
||||||
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
/// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes
|
||||||
@@ -288,14 +306,15 @@ public final class GamepadCapture {
|
|||||||
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
// the PS button must open the host's Steam overlay. Restored to .enabled on close.
|
||||||
//
|
//
|
||||||
// With forwarding OFF none of that applies — no press reaches the host, so taking the
|
// With forwarding OFF none of that applies — no press reaches the host, so taking the
|
||||||
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord
|
// user's screenshot gesture away buys nothing. NARROWED, not skipped: the CHORDS are
|
||||||
// is still read off this slot, and on tvOS it is the only controller way out of a
|
// still read off this slot — on tvOS the escape chord is the only controller way out of
|
||||||
// stream, so the chord's own four elements keep their claim. (Menu especially: leave
|
// a stream, and the stats chord the only way to the overlay — so their own elements keep
|
||||||
// its gesture attached on tvOS and the press is the system's — the chord would never
|
// their claim. (Menu especially: leave its gesture attached on tvOS and the press is the
|
||||||
// complete and the session would have no controller exit at all.)
|
// system's — the chord would never complete and the session would have no controller
|
||||||
|
// exit at all.)
|
||||||
let claimed = forwarding
|
let claimed = forwarding
|
||||||
? Array(c.physicalInputProfile.elements.values)
|
? Array(c.physicalInputProfile.elements.values)
|
||||||
: Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
: Self.chordElements.compactMap { c.physicalInputProfile.elements[$0] }
|
||||||
for element in claimed {
|
for element in claimed {
|
||||||
element.preferredSystemGestureState = .disabled
|
element.preferredSystemGestureState = .disabled
|
||||||
}
|
}
|
||||||
@@ -437,10 +456,24 @@ public final class GamepadCapture {
|
|||||||
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
let newButtons = raw | (slot.buttons & GamepadWire.guide)
|
||||||
let changed = newButtons ^ slot.buttons
|
let changed = newButtons ^ slot.buttons
|
||||||
if changed != 0 {
|
if changed != 0 {
|
||||||
|
let was = slot.buttons
|
||||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||||
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
wire?.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad))
|
||||||
}
|
}
|
||||||
slot.buttons = newButtons
|
slot.buttons = newButtons
|
||||||
|
// The stats chord, edge-triggered on the press that COMPLETES it: one cycle per
|
||||||
|
// chord rather than one per press, since a third button pressed on top finds the
|
||||||
|
// mask already complete and can't re-fire it. Read off the wire mask like the escape
|
||||||
|
// chord, which means a Select the hold-Select gesture has turned into a guide is not
|
||||||
|
// in it — a guide hold can't cycle the overlay on its way past. The buttons still
|
||||||
|
// forward (the chord is a local overlay change, not an input the host must not see).
|
||||||
|
if was & Self.statsChord != Self.statsChord,
|
||||||
|
newButtons & Self.statsChord == Self.statsChord {
|
||||||
|
// Straight to the shared tier default, like TouchMouse's three-finger tap: every
|
||||||
|
// reader (the HUD, the Settings pickers, the live session) observes it through
|
||||||
|
// @AppStorage, so no wiring back to the app is needed.
|
||||||
|
StatsVerbosity.cycle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let newAxes: [Int32] = [
|
let newAxes: [Int32] = [
|
||||||
Int32(g.leftThumbstick.xAxis.value * 32767),
|
Int32(g.leftThumbstick.xAxis.value * 32767),
|
||||||
|
|||||||
@@ -3,20 +3,40 @@
|
|||||||
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
|
// layouts). A pure function, not a singleton: the reactivity comes from callers already observing
|
||||||
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
|
// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same
|
||||||
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
|
// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place
|
||||||
// the two combine without adding a second ObservableObject or an environment key nobody else needs.
|
// the inputs combine without adding a second ObservableObject or an environment key nobody else needs.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PunktfunkShared
|
import PunktfunkShared
|
||||||
|
|
||||||
public enum GamepadUIEnvironment {
|
public enum GamepadUIEnvironment {
|
||||||
/// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`);
|
/// `DefaultsKey.gamepadUIMode`: take over only while a controller is attached. The default,
|
||||||
|
/// and what the switch meant when it was a lone Bool.
|
||||||
|
public static let modeWhenConnected = "connected"
|
||||||
|
/// `DefaultsKey.gamepadUIMode`: take over whenever the switch is on, pad or no pad — asked
|
||||||
|
/// for by people driving a TV-connected iPad or a couch Mac, where the console layout is the
|
||||||
|
/// one they want and the pad is not always awake.
|
||||||
|
public static let modeAlways = "always"
|
||||||
|
|
||||||
|
/// `enabledSetting` is the user's Settings switch (`DefaultsKey.gamepadUIEnabled`) — off means
|
||||||
|
/// the touch/desktop UI, full stop. `mode` is `DefaultsKey.gamepadUIMode`, and only matters
|
||||||
|
/// once the switch is on: `modeAlways` takes over unconditionally, anything else (including a
|
||||||
|
/// value a newer client wrote) waits for a controller.
|
||||||
|
///
|
||||||
/// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable
|
/// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable
|
||||||
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
|
/// controller is actually attached (a non-extended-profile device leaves `active` nil, which
|
||||||
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's
|
/// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function
|
||||||
/// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable
|
/// has nothing else to inspect, and it keeps the helper testable without a real `GCController`
|
||||||
/// without a real `GCController` (which XCTest can't construct).
|
/// (which XCTest can't construct).
|
||||||
public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool {
|
/// `mode` carries no default on purpose: a call site that forgot it would silently strand
|
||||||
enabledSetting && (gamepadConnected || forced)
|
/// everyone who picked Always back on "only with a controller", which is exactly the bug
|
||||||
|
/// this parameter exists to make impossible.
|
||||||
|
public static func isActive(
|
||||||
|
gamepadConnected: Bool,
|
||||||
|
enabledSetting: Bool,
|
||||||
|
mode: String
|
||||||
|
) -> Bool {
|
||||||
|
guard enabledSetting else { return false }
|
||||||
|
return mode == modeAlways || gamepadConnected || forced
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
|
/// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is
|
||||||
|
|||||||
@@ -34,10 +34,26 @@ public final class SiriRemotePointer {
|
|||||||
private var heldButtons: Set<UInt32> = []
|
private var heldButtons: Set<UInt32> = []
|
||||||
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
|
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
|
||||||
private var menuDownAt: Date?
|
private var menuDownAt: Date?
|
||||||
|
/// Counts a held Play/Pause down to `statsHold`; nil when the button is up or already
|
||||||
|
/// resolved. See `playPauseChanged`.
|
||||||
|
private var playPauseTimer: Timer?
|
||||||
|
/// The held Play/Pause has already been spent on a stats cycle, so its release must not also
|
||||||
|
/// right-click.
|
||||||
|
private var statsHoldFired = false
|
||||||
|
/// Trails a delivered right-click tap by `tapPress` to release it — see `deliverRightClick`.
|
||||||
|
private var rightReleaseTimer: Timer?
|
||||||
|
|
||||||
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
|
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
|
||||||
/// controller chord's 1.5 s — the remote has no way to trip this during gameplay.
|
/// controller chord's 1.5 s — the remote has no way to trip this during gameplay.
|
||||||
private static let disconnectHold: TimeInterval = 1.0
|
private static let disconnectHold: TimeInterval = 1.0
|
||||||
|
/// Hold Play/Pause this long to cycle the stats overlay instead of right-clicking. It is the
|
||||||
|
/// remote's only spare button, and on an Apple TV with no controller in the room this is the
|
||||||
|
/// ONLY route to the numbers (⌃⌥⇧S wants a keyboard, the three-finger tap a touchscreen).
|
||||||
|
/// Shorter than `disconnectHold`: nothing destructive rides on it.
|
||||||
|
private static let statsHold: TimeInterval = 0.5
|
||||||
|
/// pf-client-core's `TAP_PRESS`, borrowed for the deferred right-click: its release trails
|
||||||
|
/// the press by this much, so the two transitions can't fold into nothing downstream.
|
||||||
|
private static let tapPress: TimeInterval = 0.05
|
||||||
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
|
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
|
||||||
/// small; two comfortable swipes should cross a 1080p desktop.
|
/// small; two comfortable swipes should cross a 1080p desktop.
|
||||||
private static let pointerScale: Float = 1100
|
private static let pointerScale: Float = 1100
|
||||||
@@ -95,6 +111,9 @@ public final class SiriRemotePointer {
|
|||||||
old.buttonX.pressedChangedHandler = nil
|
old.buttonX.pressedChangedHandler = nil
|
||||||
old.buttonMenu.pressedChangedHandler = nil
|
old.buttonMenu.pressedChangedHandler = nil
|
||||||
}
|
}
|
||||||
|
// Timers first, then the lift: a tap whose release is still owed is held state, so
|
||||||
|
// `releaseHeld` below is what sends its button-up.
|
||||||
|
cancelPlayPause()
|
||||||
releaseHeld()
|
releaseHeld()
|
||||||
lastTouch = nil
|
lastTouch = nil
|
||||||
menuDownAt = nil
|
menuDownAt = nil
|
||||||
@@ -109,12 +128,13 @@ public final class SiriRemotePointer {
|
|||||||
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
|
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
|
||||||
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
|
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
|
||||||
}
|
}
|
||||||
// Surface click = left button; Play/Pause = right (the remote's only spare face button).
|
// Surface click = left button; Play/Pause = right (the remote's only spare face button),
|
||||||
|
// or — held — the stats-overlay cycle. See `playPauseChanged`.
|
||||||
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
|
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||||
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
|
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
|
||||||
}
|
}
|
||||||
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
|
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||||
MainActor.assumeIsolated { self?.setButton(3, down: pressed) }
|
MainActor.assumeIsolated { self?.playPauseChanged(pressed: pressed) }
|
||||||
}
|
}
|
||||||
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
|
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||||
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
|
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
|
||||||
@@ -149,6 +169,76 @@ public final class SiriRemotePointer {
|
|||||||
connection.send(.mouseButton(button, down: down))
|
connection.send(.mouseButton(button, down: down))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play/Pause: a TAP right-clicks, a HOLD (`statsHold`) cycles the stats overlay instead.
|
||||||
|
///
|
||||||
|
/// The right button is therefore DEFERRED until the press resolves, rather than going down on
|
||||||
|
/// contact: once the host has seen a button-down there is no taking it back, and a right
|
||||||
|
/// button held for half a second is a context menu on every desktop this streams. The shape
|
||||||
|
/// is the hold-Select gesture's (`GamepadCapture.gestureFiltered`) — suppress, then deliver a
|
||||||
|
/// tap on release or the gesture past the threshold — so the two behave alike.
|
||||||
|
private func playPauseChanged(pressed: Bool) {
|
||||||
|
if pressed {
|
||||||
|
statsHoldFired = false
|
||||||
|
let timer = Timer(timeInterval: Self.statsHold, repeats: false) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.statsHoldElapsed() }
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
playPauseTimer?.invalidate()
|
||||||
|
playPauseTimer = timer
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playPauseTimer?.invalidate()
|
||||||
|
playPauseTimer = nil
|
||||||
|
// The hold already spent this press on a cycle — its release clicks nothing.
|
||||||
|
guard !statsHoldFired else {
|
||||||
|
statsHoldFired = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deliverRightClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The threshold passed with Play/Pause still down → cycle the overlay and consume the press.
|
||||||
|
/// Writes the shared `statsVerbosity` default every reader observes through @AppStorage — the
|
||||||
|
/// same cycle as ⌃⌥⇧S, the three-finger tap and the controller's Select + X.
|
||||||
|
private func statsHoldElapsed() {
|
||||||
|
playPauseTimer = nil
|
||||||
|
statsHoldFired = true
|
||||||
|
StatsVerbosity.cycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Play/Pause tap, delivered now that it resolved as one: the right button down, its
|
||||||
|
/// release `tapPress` behind so the pair can't collapse into nothing downstream.
|
||||||
|
private func deliverRightClick() {
|
||||||
|
// A previous tap's owed release goes out FIRST — two taps inside `tapPress` would
|
||||||
|
// otherwise send the host two downs in a row (the rule GamepadCapture's held-back Select
|
||||||
|
// tap follows for the same reason).
|
||||||
|
finishRightClick()
|
||||||
|
setButton(3, down: true)
|
||||||
|
let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.finishRightClick() }
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
rightReleaseTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a tap's right button if one is still owed; nothing otherwise.
|
||||||
|
private func finishRightClick() {
|
||||||
|
guard rightReleaseTimer != nil else { return }
|
||||||
|
rightReleaseTimer?.invalidate()
|
||||||
|
rightReleaseTimer = nil
|
||||||
|
setButton(3, down: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop any in-flight Play/Pause state (unbind / stop). Timers only — a right button already
|
||||||
|
/// sent down is held state, and `releaseHeld` is what lifts it.
|
||||||
|
private func cancelPlayPause() {
|
||||||
|
playPauseTimer?.invalidate()
|
||||||
|
playPauseTimer = nil
|
||||||
|
rightReleaseTimer?.invalidate()
|
||||||
|
rightReleaseTimer = nil
|
||||||
|
statsHoldFired = false
|
||||||
|
}
|
||||||
|
|
||||||
private func menuChanged(pressed: Bool) {
|
private func menuChanged(pressed: Bool) {
|
||||||
if pressed {
|
if pressed {
|
||||||
menuDownAt = Date()
|
menuDownAt = Date()
|
||||||
|
|||||||
@@ -176,16 +176,23 @@ public enum DefaultsKey {
|
|||||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||||
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
|
/// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly
|
||||||
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library)
|
/// layout (the console launcher, gamepad-navigable settings, a coverflow-style library).
|
||||||
/// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`.
|
/// On by default; WHEN it takes over is `gamepadUIMode`. See `GamepadUIEnvironment.isActive`.
|
||||||
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled"
|
||||||
|
/// When `gamepadUIEnabled` actually takes over: `"connected"` (the default — only while a
|
||||||
|
/// usable controller is attached, the behaviour this switch has always had) or `"always"`,
|
||||||
|
/// for someone who prefers the console layout with no pad in reach (a TV-connected iPad, a
|
||||||
|
/// Mac driven from the couch). Read only while `gamepadUIEnabled` is on, which is why the
|
||||||
|
/// settings rows hide it when the switch is off. Anything unrecognized reads as
|
||||||
|
/// `"connected"`. A device preference, never part of a stream profile.
|
||||||
|
public static let gamepadUIMode = "punktfunk.gamepadUIMode"
|
||||||
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
/// Which colour family the gamepad UI's living backdrop drifts through — a
|
||||||
/// `GamepadPalette` id ("violet" = the brand default, then "tide"/"forest"/"ember"/
|
/// `GamepadPalette` id ("violet" = the brand default, then "oled"/"nebula"/"abyss"/"ember"/
|
||||||
/// "rose"/"graphite"). The cross-client `ui_palette` key: the desktop console and the
|
/// "moss"/"graphite", then the pale ones). The cross-client `ui_palette` key: the desktop
|
||||||
/// Android client carry the same table under the same names. Presentation only, so it is
|
/// console and the Android client carry the same table under the same names. Presentation
|
||||||
/// a device preference and never part of a stream profile. An unknown value reads as the
|
/// only, so it is a device preference and never part of a stream profile. An unknown value
|
||||||
/// default rather than failing — a newer client may have shipped a palette this build
|
/// reads as the default rather than failing — a newer client may have shipped a palette this
|
||||||
/// doesn't know.
|
/// build doesn't know.
|
||||||
public static let uiPalette = "punktfunk.uiPalette"
|
public static let uiPalette = "punktfunk.uiPalette"
|
||||||
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
/// iPhone: ALSO play the rumble the host addresses to controller 1 (wire pad 0) on this
|
||||||
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
/// device's own Taptic Engine — for phone-clip pads that ship without rumble motors, where
|
||||||
|
|||||||
@@ -65,13 +65,25 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable {
|
|||||||
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
|
SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96),
|
||||||
]
|
]
|
||||||
|
|
||||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale
|
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale
|
||||||
/// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way.
|
/// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way.
|
||||||
public static let all: [GamepadPalette] = [
|
public static let all: [GamepadPalette] = [
|
||||||
// --- dark fields (white ink) ---
|
// --- dark fields (white ink) ---
|
||||||
GamepadPalette(
|
GamepadPalette(
|
||||||
id: "violet", name: "Violet", stops: [],
|
id: "violet", name: "Violet", stops: [],
|
||||||
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||||
|
GamepadPalette(
|
||||||
|
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||||
|
// no power. The first two stops are literally (0,0,0), so the shaded half of the
|
||||||
|
// field is genuinely off rather than "very dark grey", and the ground is pure black
|
||||||
|
// too: the calm mix on the form screens lifts toward nothing. What is left is a
|
||||||
|
// faint indigo→violet ember in the bright corner. The accent stays the brand violet
|
||||||
|
// — focus has to be findable on black.
|
||||||
|
id: "oled", name: "OLED",
|
||||||
|
stops: [SIMD3(0.000, 0.000, 0.000), SIMD3(0.000, 0.000, 0.000),
|
||||||
|
SIMD3(0.010, 0.020, 0.100), SIMD3(0.045, 0.016, 0.115),
|
||||||
|
SIMD3(0.120, 0.024, 0.130)],
|
||||||
|
ground: SIMD3(0, 0, 0), accent: SIMD3(0.525, 0.471, 0.961), light: false),
|
||||||
GamepadPalette(
|
GamepadPalette(
|
||||||
// Deep indigo climbing through violet into a hot magenta.
|
// Deep indigo climbing through violet into a hot magenta.
|
||||||
id: "nebula", name: "Nebula",
|
id: "nebula", name: "Nebula",
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import XCTest
|
|||||||
|
|
||||||
/// The escape chord's mask and its GameController alias list have to describe the same four
|
/// The escape chord's mask and its GameController alias list have to describe the same four
|
||||||
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
|
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
|
||||||
/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops
|
/// is on, but only of `chordElements` — `escapeChordElements` plus the stats chord's — while it is
|
||||||
/// covering the mask, the missing button's press stays the system's and the chord never completes.
|
/// off, so if this alias list ever stops covering the mask, the missing button's press stays the
|
||||||
|
/// system's and the chord never completes. (`GamepadStatsChordTests` pins the claim list itself.)
|
||||||
///
|
///
|
||||||
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
|
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
|
||||||
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
|
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
|
||||||
|
|||||||
@@ -46,12 +46,29 @@ final class GamepadPaletteTests: XCTestCase {
|
|||||||
func testTableMatchesTheOtherClients() {
|
func testTableMatchesTheOtherClients() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
GamepadPalette.all.map(\.id),
|
GamepadPalette.all.map(\.id),
|
||||||
["violet", "nebula", "abyss", "ember", "moss", "graphite",
|
["violet", "oled", "nebula", "abyss", "ember", "moss", "graphite",
|
||||||
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
|
"holo", "sunset", "bloom", "dawn", "mint", "opal"])
|
||||||
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
// Dark fields lead, pale ones follow, so stepping the row walks one direction.
|
||||||
let firstLight = GamepadPalette.all.firstIndex { $0.light }
|
let firstLight = GamepadPalette.all.firstIndex { $0.light }
|
||||||
XCTAssertEqual(firstLight, 6)
|
XCTAssertEqual(firstLight, 7)
|
||||||
XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light))
|
XCTAssertTrue(GamepadPalette.all.dropFirst(7).allSatisfy(\.light))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OLED is the one palette whose selling point is measurable: it has to be genuinely black,
|
||||||
|
/// not merely the darkest of the dark fields.
|
||||||
|
func testOLEDIsActuallyBlack() {
|
||||||
|
let oled = GamepadPalette.named("oled")
|
||||||
|
XCTAssertEqual(oled.ground, SIMD3(0, 0, 0), "the calm lift must be nothing")
|
||||||
|
let cells = oled.meshColors
|
||||||
|
XCTAssertGreaterThanOrEqual(
|
||||||
|
cells.filter { luma($0) == 0 }.count, 3,
|
||||||
|
"the shaded corner has to be switched off, not dimmed")
|
||||||
|
let mean = cells.map(luma).reduce(0, +) / Double(cells.count)
|
||||||
|
let darkestOther = GamepadPalette.all
|
||||||
|
.filter { $0.id != "oled" }
|
||||||
|
.map { p in p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) }
|
||||||
|
.min() ?? 0
|
||||||
|
XCTAssertLessThan(mean, darkestOther / 2, "oled is barely darker than \(darkestOther)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
|
/// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import GameController
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// The stats chord (Select + X) has the same drift hazard as the escape chord it sits beside: its
|
||||||
|
/// mask and its GameController alias list must describe the same buttons, and every element some
|
||||||
|
/// chord reads has to appear in the list a NON-forwarding slot claims — otherwise that button's
|
||||||
|
/// press stays the system's and the chord silently never completes.
|
||||||
|
///
|
||||||
|
/// It matters most on tvOS, where this is the only way to the statistics overlay at all (no
|
||||||
|
/// keyboard for ⌃⌥⇧S, no touchscreen for the three-finger tap). The failure looks like nothing
|
||||||
|
/// happening, so it is pinned here rather than left to the comments.
|
||||||
|
@MainActor
|
||||||
|
final class GamepadStatsChordTests: XCTestCase {
|
||||||
|
|
||||||
|
/// The intended alias↔bit pairing, spelled out independently of the implementation.
|
||||||
|
private let pairing: [(alias: String, bit: UInt32)] = [
|
||||||
|
(GCInputButtonOptions, GamepadWire.back),
|
||||||
|
(GCInputButtonX, GamepadWire.x),
|
||||||
|
]
|
||||||
|
|
||||||
|
func testChordMaskIsExactlyTheTwoPairedButtons() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
pairing.reduce(UInt32(0)) { $0 | $1.bit },
|
||||||
|
GamepadCapture.statsChord,
|
||||||
|
"the chord mask and the alias pairing describe different buttons")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAliasListMirrorsTheMask() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamepadCapture.statsChordElements.count,
|
||||||
|
GamepadCapture.statsChord.nonzeroBitCount,
|
||||||
|
"alias list and chord mask differ in size")
|
||||||
|
XCTAssertEqual(GamepadCapture.statsChordElements, pairing.map(\.alias))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two chords must not be reachable through one another: pressing toward the exit chord
|
||||||
|
/// may not cycle the overlay on the way, and holding the stats chord may not arm a disconnect.
|
||||||
|
/// Select is the one button they share by design — everything else has to be disjoint.
|
||||||
|
func testChordsOverlapOnlyOnSelect() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
GamepadCapture.statsChord & GamepadCapture.escapeChord,
|
||||||
|
GamepadWire.back,
|
||||||
|
"the stats and escape chords share a button other than Select")
|
||||||
|
// Neither is a subset of the other, so completing one can never complete the other.
|
||||||
|
XCTAssertNotEqual(
|
||||||
|
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.statsChord)
|
||||||
|
XCTAssertNotEqual(
|
||||||
|
GamepadCapture.statsChord & GamepadCapture.escapeChord, GamepadCapture.escapeChord)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `chordElements` is what `openSlot` claims when forwarding is OFF. It must cover BOTH
|
||||||
|
/// chords' aliases and repeat none of them (a duplicate would mean a bit with no element).
|
||||||
|
func testClaimListCoversBothChordsWithoutDuplicates() {
|
||||||
|
let claim = GamepadCapture.chordElements
|
||||||
|
for alias in GamepadCapture.escapeChordElements + GamepadCapture.statsChordElements {
|
||||||
|
XCTAssertTrue(claim.contains(alias), "\(alias) is read by a chord but never claimed")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(Set(claim).count, claim.count, "a repeated alias in the claim list")
|
||||||
|
// Shared Select means the union is one shorter than the two lists laid end to end.
|
||||||
|
XCTAssertEqual(
|
||||||
|
claim.count,
|
||||||
|
GamepadCapture.escapeChordElements.count + GamepadCapture.statsChordElements.count - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cycle is a pure rotation through the four tiers — the chord fires `StatsVerbosity.cycle`,
|
||||||
|
/// and a tier that dead-ended would strand a tvOS user with no other way back.
|
||||||
|
func testCycleReachesEveryTierAndReturns() {
|
||||||
|
var tier = StatsVerbosity.off
|
||||||
|
var seen: [StatsVerbosity] = []
|
||||||
|
for _ in 0..<StatsVerbosity.allCases.count {
|
||||||
|
seen.append(tier)
|
||||||
|
tier = tier.next()
|
||||||
|
}
|
||||||
|
XCTAssertEqual(Set(seen).count, StatsVerbosity.allCases.count, "a tier is unreachable")
|
||||||
|
XCTAssertEqual(tier, .off, "the cycle does not return to where it started")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,58 @@
|
|||||||
// GamepadUIEnvironment.isActive is a pure AND — table-tested exhaustively over its 2x2 inputs.
|
// GamepadUIEnvironment.isActive is pure — table-tested exhaustively over its inputs.
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import PunktfunkKit
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
final class GamepadUIEnvironmentTests: XCTestCase {
|
final class GamepadUIEnvironmentTests: XCTestCase {
|
||||||
func testActiveOnlyWhenEnabledAndConnected() {
|
private let connected = GamepadUIEnvironment.modeWhenConnected
|
||||||
XCTAssertTrue(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: true))
|
private let always = GamepadUIEnvironment.modeAlways
|
||||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: true, enabledSetting: false))
|
|
||||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: true))
|
/// The default mode is the behaviour the switch had when it was a lone Bool, so an install
|
||||||
XCTAssertFalse(GamepadUIEnvironment.isActive(gamepadConnected: false, enabledSetting: false))
|
/// that never sees the new row is exactly where it was.
|
||||||
|
func testWhenConnectedIsAPlainAnd() {
|
||||||
|
XCTAssertTrue(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: true, enabledSetting: true, mode: connected))
|
||||||
|
XCTAssertFalse(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: true, enabledSetting: false, mode: connected))
|
||||||
|
XCTAssertFalse(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: false, enabledSetting: true, mode: connected))
|
||||||
|
XCTAssertFalse(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: false, enabledSetting: false, mode: connected))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always drops the controller from the decision entirely — but NOT the switch, which stays
|
||||||
|
/// the one way back to the touch UI.
|
||||||
|
func testAlwaysIgnoresTheControllerButNotTheSwitch() {
|
||||||
|
XCTAssertTrue(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: false, enabledSetting: true, mode: always))
|
||||||
|
XCTAssertTrue(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: true, enabledSetting: true, mode: always))
|
||||||
|
XCTAssertFalse(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: false, enabledSetting: false, mode: always))
|
||||||
|
XCTAssertFalse(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: true, enabledSetting: false, mode: always))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A value a newer client wrote must wait for a controller, never strand this build in a
|
||||||
|
/// layout it has no way back out of.
|
||||||
|
func testUnknownModeWaitsForAController() {
|
||||||
|
XCTAssertFalse(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: false, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: true, enabledSetting: true, mode: "whenever-i-say-so"))
|
||||||
|
XCTAssertFalse(
|
||||||
|
GamepadUIEnvironment.isActive(
|
||||||
|
gamepadConnected: false, enabledSetting: true, mode: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-26
@@ -303,6 +303,58 @@ def _native_client() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# The one architecture the flatpak client is built for.
|
||||||
|
_FLATPAK_ARCH = "x86_64"
|
||||||
|
|
||||||
|
|
||||||
|
def _flatpak_ref() -> dict | None:
|
||||||
|
"""The INSTALLED client flatpak resolved to a SCOPE and a BRANCH, or None when there is none.
|
||||||
|
|
||||||
|
``{"scope": "--user"|"--system", "branch": "canary", "ref": "io.unom.Punktfunk//canary"}``.
|
||||||
|
|
||||||
|
⭐⭐ **Naming no branch is not a shorthand for "the only one".** flatpak refuses an ambiguous
|
||||||
|
ref rather than guessing at one, and the ambiguity does not need two branches *installed*:
|
||||||
|
the punktfunk remote publishes `stable` AND `canary`, so an unqualified
|
||||||
|
``flatpak remote-info <origin> io.unom.Punktfunk`` errors with "Multiple branches available"
|
||||||
|
on a Deck that has exactly one. That error is why the client update check silently answered
|
||||||
|
"up to date" on every Deck — so every query downstream now names the ref in full.
|
||||||
|
|
||||||
|
Read off the exported tree rather than by shelling out to ``flatpak list``, because
|
||||||
|
:func:`_client_argv` is on the path of every headless call and a subprocess per call would be
|
||||||
|
absurd (the same reason :func:`_flatpak_installed` reads the filesystem). ``active`` is the
|
||||||
|
symlink flatpak points at the deployed commit — its presence is what makes a branch directory
|
||||||
|
an INSTALL rather than the leftovers of one.
|
||||||
|
|
||||||
|
With more than one branch installed, `stable` wins, because that is the branch a plain
|
||||||
|
``flatpak run`` resolves to: the check has to describe the client the launcher really starts,
|
||||||
|
or a stale `stable` silently beats a current `canary` in both places at once.
|
||||||
|
"""
|
||||||
|
if not _flatpak():
|
||||||
|
return None
|
||||||
|
for root, scope in (
|
||||||
|
(Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak", "--user"),
|
||||||
|
(Path("/var/lib/flatpak"), "--system"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
branches = sorted(
|
||||||
|
p.name for p in (root / "app" / APP_ID / _FLATPAK_ARCH).iterdir()
|
||||||
|
if (p / "active").exists()
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
continue # not installed in this scope
|
||||||
|
if not branches:
|
||||||
|
continue
|
||||||
|
branch = "stable" if "stable" in branches else branches[0]
|
||||||
|
if len(branches) > 1:
|
||||||
|
decky.logger.warning(
|
||||||
|
"%s is installed on %d branches (%s) — using %s, the one `flatpak run` resolves "
|
||||||
|
"to; uninstall the others so the client you launch is the client we update",
|
||||||
|
APP_ID, len(branches), ", ".join(branches), branch,
|
||||||
|
)
|
||||||
|
return {"scope": scope, "branch": branch, "ref": f"{APP_ID}//{branch}"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _flatpak_installed() -> bool:
|
def _flatpak_installed() -> bool:
|
||||||
"""True when the flatpak APP is actually installed — not merely that `flatpak` exists.
|
"""True when the flatpak APP is actually installed — not merely that `flatpak` exists.
|
||||||
|
|
||||||
@@ -310,10 +362,7 @@ def _flatpak_installed() -> bool:
|
|||||||
because this is on the path of every headless call and a subprocess per call would be absurd.
|
because this is on the path of every headless call and a subprocess per call would be absurd.
|
||||||
Both scopes count: the Deck installs --user, a distro image may ship it system-wide.
|
Both scopes count: the Deck installs --user, a distro image may ship it system-wide.
|
||||||
"""
|
"""
|
||||||
if not _flatpak():
|
return _flatpak_ref() is not None
|
||||||
return False
|
|
||||||
user = Path(decky.DECKY_USER_HOME) / ".local" / "share" / "flatpak" / "app" / APP_ID
|
|
||||||
return user.exists() or Path("/var/lib/flatpak/app", APP_ID).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _client_argv() -> list[str] | None:
|
def _client_argv() -> list[str] | None:
|
||||||
@@ -323,15 +372,21 @@ def _client_argv() -> list[str] | None:
|
|||||||
behaving exactly as it did. A native binary is the fallback — and on a machine with no
|
behaving exactly as it did. A native binary is the fallback — and on a machine with no
|
||||||
flatpak client, the thing that makes the plugin work at all. `PF_DECKY_CLIENT=native|flatpak`
|
flatpak client, the thing that makes the plugin work at all. `PF_DECKY_CLIENT=native|flatpak`
|
||||||
forces one when a machine has both.
|
forces one when a machine has both.
|
||||||
|
|
||||||
|
The branch is PINNED (`--branch=`, which keeps the app id last — :func:`_cli_argv` appends
|
||||||
|
`--command=` and flatpak treats everything after the id as the app's own argv), so the client
|
||||||
|
this launches is the exact ref :func:`_client_update_state` checks and :meth:`Plugin.
|
||||||
|
update_client` updates.
|
||||||
"""
|
"""
|
||||||
forced = os.environ.get("PF_DECKY_CLIENT", "").strip().lower()
|
forced = os.environ.get("PF_DECKY_CLIENT", "").strip().lower()
|
||||||
native = _native_client()
|
native = _native_client()
|
||||||
if forced == "native":
|
if forced == "native":
|
||||||
return [native] if native else None
|
return [native] if native else None
|
||||||
if forced != "flatpak" and not _flatpak_installed() and native:
|
ref = _flatpak_ref()
|
||||||
|
if forced != "flatpak" and not ref and native:
|
||||||
return [native]
|
return [native]
|
||||||
if _flatpak_installed():
|
if ref:
|
||||||
return [_flatpak(), "run", "--arch=x86_64", APP_ID]
|
return [_flatpak(), "run", f"--arch={_FLATPAK_ARCH}", f"--branch={ref['branch']}", APP_ID]
|
||||||
return [native] if native else None
|
return [native] if native else None
|
||||||
|
|
||||||
|
|
||||||
@@ -575,27 +630,44 @@ def _looks_outdated(stderr: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _client_update_state() -> dict:
|
async def _client_update_state() -> dict:
|
||||||
"""Is a newer commit of the flatpak client available in the remote it tracks? The client is a
|
"""Is a newer commit of the flatpak client available in the remote it tracks? The client
|
||||||
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
|
versions independently of this plugin, so we compare the installed commit against the
|
||||||
it versions independently of this plugin — so we compare the installed commit against the
|
remote's here and let the QAM offer an update in the scope the client is actually installed
|
||||||
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
|
in — a per-user install is one ``sudo flatpak update`` (system-scope) never reaches.
|
||||||
(not installed, no flatpak, offline).
|
|
||||||
|
|
||||||
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
|
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
|
||||||
flatpak built from main between releases carries the release's crate version, so the
|
flatpak built from main between releases carries the release's crate version, so the
|
||||||
signed-manifest comparison the native path uses would call it up to date when it isn't.
|
signed-manifest comparison the native path uses would call it up to date when it isn't.
|
||||||
Native installs have no commit to compare and go through :func:`_native_update_state`."""
|
Native installs have no commit to compare and go through :func:`_native_update_state`.
|
||||||
state = {"available": False, "installed": "", "remote": ""}
|
|
||||||
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
⚠ Every query names the ref IN FULL (see :func:`_flatpak_ref`) — the remote publishes both
|
||||||
|
`stable` and `canary`, and an unqualified one is an error, not a default."""
|
||||||
|
state = {"available": False, "installed": "", "remote": "", "error": ""}
|
||||||
|
ref = _flatpak_ref()
|
||||||
|
if not ref:
|
||||||
|
return state # no flatpak client in either scope
|
||||||
|
scope, full = ref["scope"], ref["ref"]
|
||||||
|
rc, info = await _flatpak_capture(["info", scope, full], timeout=10.0)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return state # client not installed as a user app / no flatpak
|
decky.logger.warning("flatpak info %s %s failed (rc=%s): %s", scope, full, rc, info[-200:])
|
||||||
|
state["error"] = "client-unavailable"
|
||||||
|
return state
|
||||||
state["installed"] = _field_from(info, "Commit")
|
state["installed"] = _field_from(info, "Commit")
|
||||||
origin = _field_from(info, "Origin")
|
origin = _field_from(info, "Origin")
|
||||||
if not origin:
|
if not origin:
|
||||||
|
state["error"] = "no-origin" # a sideloaded bundle tracks no remote to compare against
|
||||||
return state
|
return state
|
||||||
rc, rinfo = await _flatpak_capture(["remote-info", "--user", origin, APP_ID], timeout=25.0)
|
rc, rinfo = await _flatpak_capture(["remote-info", scope, origin, full], timeout=25.0)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return state # remote unreachable — treat as "up to date", retry next check
|
# ⭐ NOT "up to date". Silently swallowing this is precisely how the whole leg stayed
|
||||||
|
# broken in the field: an unqualified ref made every one of these calls fail, and
|
||||||
|
# returning `available=False` dressed the failure up as good news. A check that could
|
||||||
|
# not run says so, and the panel says so too.
|
||||||
|
decky.logger.warning(
|
||||||
|
"flatpak remote-info %s %s failed (rc=%s): %s", origin, full, rc, rinfo.strip()[-200:]
|
||||||
|
)
|
||||||
|
state["error"] = "fetch-failed"
|
||||||
|
return state
|
||||||
state["remote"] = _field_from(rinfo, "Commit")
|
state["remote"] = _field_from(rinfo, "Commit")
|
||||||
state["available"] = bool(
|
state["available"] = bool(
|
||||||
state["installed"] and state["remote"] and state["installed"] != state["remote"]
|
state["installed"] and state["remote"] and state["installed"] != state["remote"]
|
||||||
@@ -946,8 +1018,9 @@ class Plugin:
|
|||||||
async def update_client(self) -> dict:
|
async def update_client(self) -> dict:
|
||||||
"""Update the **client**, by whichever route this box's install actually supports.
|
"""Update the **client**, by whichever route this box's install actually supports.
|
||||||
|
|
||||||
* **flatpak** — ``flatpak update --user`` in the USER installation, the scope a Steam
|
* **flatpak** — ``flatpak update`` against the FULL ref, in the scope the client is
|
||||||
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
|
installed in (a per-user install is one ``sudo flatpak update`` never reaches, and an
|
||||||
|
unqualified ref is an error on a remote publishing more than one branch).
|
||||||
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
|
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
|
||||||
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
|
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
|
||||||
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
|
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
|
||||||
@@ -960,18 +1033,22 @@ class Plugin:
|
|||||||
"""
|
"""
|
||||||
if not _client_is_flatpak():
|
if not _client_is_flatpak():
|
||||||
return await self._update_native_client()
|
return await self._update_native_client()
|
||||||
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
ref = _flatpak_ref()
|
||||||
|
if not ref:
|
||||||
|
return {"ok": False, "updated": False, "error": "client-unavailable"}
|
||||||
|
scope, full = ref["scope"], ref["ref"]
|
||||||
|
_, before = await _flatpak_capture(["info", scope, full], timeout=10.0)
|
||||||
before_commit = _field_from(before, "Commit")
|
before_commit = _field_from(before, "Commit")
|
||||||
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
|
rc, out = await _flatpak_capture(["update", scope, "-y", full], timeout=300.0)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
decky.logger.warning("flatpak client update failed (rc=%s): %s", rc, out[-400:])
|
decky.logger.warning("flatpak client update failed (rc=%s): %s", rc, out[-400:])
|
||||||
return {"ok": False, "updated": False, "error": "update-failed"}
|
return {"ok": False, "updated": False, "error": "update-failed"}
|
||||||
_, after = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
|
_, after = await _flatpak_capture(["info", scope, full], timeout=10.0)
|
||||||
after_commit = _field_from(after, "Commit")
|
after_commit = _field_from(after, "Commit")
|
||||||
updated = bool(before_commit and after_commit and before_commit != after_commit)
|
updated = bool(before_commit and after_commit and before_commit != after_commit)
|
||||||
decky.logger.info(
|
decky.logger.info(
|
||||||
"flatpak client update: %s -> %s (updated=%s)",
|
"flatpak client update (%s %s): %s -> %s (updated=%s)",
|
||||||
before_commit[:10], after_commit[:10], updated,
|
scope, full, before_commit[:10], after_commit[:10], updated,
|
||||||
)
|
)
|
||||||
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
_update_cache["data"] = None # invalidate the cached "update available" snapshot
|
||||||
return {"ok": True, "updated": updated}
|
return {"ok": True, "updated": updated}
|
||||||
@@ -1018,12 +1095,20 @@ class Plugin:
|
|||||||
try:
|
try:
|
||||||
if _client_is_flatpak():
|
if _client_is_flatpak():
|
||||||
cu = await _client_update_state()
|
cu = await _client_update_state()
|
||||||
|
ref = _flatpak_ref()
|
||||||
result["client_update_available"] = bool(cu["available"])
|
result["client_update_available"] = bool(cu["available"])
|
||||||
result["client_current"] = (cu["installed"] or "")[:10]
|
result["client_current"] = (cu["installed"] or "")[:10]
|
||||||
result["client_latest"] = (cu["remote"] or "")[:10]
|
result["client_latest"] = (cu["remote"] or "")[:10]
|
||||||
result["client_install"] = "flatpak"
|
result["client_install"] = "flatpak"
|
||||||
result["client_applier"] = "flatpak"
|
result["client_applier"] = "flatpak"
|
||||||
result["client_command"] = f"flatpak update --user {APP_ID}"
|
# The line a user could actually run — same scope, same full ref we use. The old
|
||||||
|
# unqualified one errored out ("Multiple branches available") when pasted, too.
|
||||||
|
result["client_command"] = (
|
||||||
|
f"flatpak update {ref['scope']} -y {ref['ref']}" if ref else ""
|
||||||
|
)
|
||||||
|
if cu["error"]:
|
||||||
|
# Same contract as the native leg: "couldn't tell" is never "up to date".
|
||||||
|
result["client_error"] = cu["error"]
|
||||||
else:
|
else:
|
||||||
nu = await _native_update_state()
|
nu = await _native_update_state()
|
||||||
result["client_update_available"] = bool(nu.get("update_available"))
|
result["client_update_available"] = bool(nu.get("update_available"))
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ sys.modules["decky"] = decky
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
import main # noqa: E402 (the plugin backend)
|
import main # noqa: E402 (the plugin backend)
|
||||||
|
|
||||||
|
# The argv fixtures below monkey-patch `_client_argv` to pin one install shape; the
|
||||||
|
# _flatpak_ref block wants the REAL resolver back, so keep a handle on it.
|
||||||
|
_real_client_argv = main._client_argv
|
||||||
|
|
||||||
failures = 0
|
failures = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +79,60 @@ check("cli argv: native without a sibling CLI is None", main._cli_argv() is None
|
|||||||
(tmp / "punktfunk").write_text("")
|
(tmp / "punktfunk").write_text("")
|
||||||
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
|
check("cli argv: native sibling found", main._cli_argv() == [str(tmp / "punktfunk")])
|
||||||
|
|
||||||
|
# ---- _flatpak_ref: the branch must be NAMED, always ---------------------------------------
|
||||||
|
#
|
||||||
|
# The bug this exists to prevent: every client-update query used to name no branch, and the
|
||||||
|
# punktfunk remote publishes `stable` AND `canary` — so `flatpak remote-info <origin>
|
||||||
|
# io.unom.Punktfunk` failed with "Multiple branches available", the check swallowed the failure,
|
||||||
|
# and the panel reported the client up to date forever. One branch INSTALLED is not enough to
|
||||||
|
# make the query unambiguous; the ambiguity lives on the remote.
|
||||||
|
shutil.rmtree("/tmp/pf-test-home", ignore_errors=True)
|
||||||
|
_fp_root = Path("/tmp/pf-test-home/.local/share/flatpak/app/io.unom.Punktfunk/x86_64")
|
||||||
|
main._flatpak = lambda: "/usr/bin/flatpak"
|
||||||
|
main._client_argv = _real_client_argv # undo the fixture patches above
|
||||||
|
|
||||||
|
check("ref: nothing installed => None", main._flatpak_ref() is None)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_branch(name: str):
|
||||||
|
"""A deployed branch: the `active` symlink is what distinguishes an install from leftovers."""
|
||||||
|
commit = _fp_root / name / "deadbeef"
|
||||||
|
commit.mkdir(parents=True, exist_ok=True)
|
||||||
|
(_fp_root / name / "active").symlink_to("deadbeef")
|
||||||
|
|
||||||
|
|
||||||
|
(_fp_root / "canary").mkdir(parents=True, exist_ok=True)
|
||||||
|
check("ref: a branch dir without `active` is leftovers, not an install", main._flatpak_ref() is None)
|
||||||
|
|
||||||
|
_install_branch("canary")
|
||||||
|
ref = main._flatpak_ref()
|
||||||
|
check("ref: the single installed branch is used", ref == {
|
||||||
|
"scope": "--user", "branch": "canary", "ref": "io.unom.Punktfunk//canary",
|
||||||
|
})
|
||||||
|
check(
|
||||||
|
"ref: the launcher pins that branch, app id still LAST",
|
||||||
|
main._client_argv() == [
|
||||||
|
"/usr/bin/flatpak", "run", "--arch=x86_64", "--branch=canary", "io.unom.Punktfunk",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# The pin must survive _cli_argv's rewrite, or the CLI runs a different build than the GUI.
|
||||||
|
check(
|
||||||
|
"ref: --command= is inserted before the app id, keeping the pin",
|
||||||
|
main._cli_argv() == [
|
||||||
|
"/usr/bin/flatpak", "run", "--arch=x86_64", "--branch=canary",
|
||||||
|
"--command=punktfunk", "io.unom.Punktfunk",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Two installed: `stable` is what a plain `flatpak run` resolves to, so it must be what we
|
||||||
|
# check and update too — otherwise a leftover stale `stable` wins the launch while `canary`
|
||||||
|
# gets the update, and the two halves disagree about which client is even running.
|
||||||
|
_install_branch("stable")
|
||||||
|
check("ref: with both installed, stable wins (what `flatpak run` picks)",
|
||||||
|
main._flatpak_ref()["branch"] == "stable")
|
||||||
|
|
||||||
|
shutil.rmtree("/tmp/pf-test-home", ignore_errors=True)
|
||||||
|
|
||||||
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
|
# ---- _cli_error: the CLI's exit-code contract -------------------------------------------
|
||||||
#
|
#
|
||||||
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
|
# Exit 5 + `unknown command` is how a client too old for a verb announces itself — the ONE
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ export interface UpdateInfo {
|
|||||||
client_applier: string;
|
client_applier: string;
|
||||||
client_command: string; // one copy-pastable line that updates this install by hand
|
client_command: string; // one copy-pastable line that updates this install by hand
|
||||||
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
|
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
|
||||||
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
|
// The client check couldn't complete — NEVER rendered as "up to date". "client-outdated" |
|
||||||
|
// "client-unavailable" | "no-origin" | "fetch-failed" (flatpak: the remote was unreachable).
|
||||||
|
client_error?: string;
|
||||||
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
applyUpdate,
|
applyUpdate,
|
||||||
checkForUpdatesNow,
|
checkForUpdatesNow,
|
||||||
clientUpdateIsManualOnly,
|
clientUpdateIsManualOnly,
|
||||||
|
clientUpdateIsOneTap,
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
HostView,
|
HostView,
|
||||||
needsPair,
|
needsPair,
|
||||||
@@ -183,8 +184,11 @@ const QamPanel: FC = () => {
|
|||||||
onClick={() => applyUpdate(update!, check)}
|
onClick={() => applyUpdate(update!, check)}
|
||||||
label={
|
label={
|
||||||
update!.update_available
|
update!.update_available
|
||||||
? `Plugin v${update!.current} → v${update!.latest}${
|
? // "+ client" only when this tap will really install it. A manual-only
|
||||||
update!.client_update_available ? " + client" : ""
|
// client rides along as a toast with the command, and promising it in the
|
||||||
|
// label would make that read as a failure.
|
||||||
|
`Plugin v${update!.current} → v${update!.latest}${
|
||||||
|
clientUpdateIsOneTap(update) ? " + client" : ""
|
||||||
}`
|
}`
|
||||||
: "New client version"
|
: "New client version"
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-24
@@ -325,6 +325,22 @@ mod session_main {
|
|||||||
};
|
};
|
||||||
// Before the struct literal — `vulkan` moves into it below.
|
// Before the struct literal — `vulkan` moves into it below.
|
||||||
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
|
let phase_lock = vulkan.as_ref().is_some_and(|v| v.present_timing);
|
||||||
|
// …and the 4:4:4 promise, for the same reason: asked while the device bundle is
|
||||||
|
// still borrowable. `&&` short-circuits, so a box that never enabled Full chroma
|
||||||
|
// pays no capability queries for a feature it does not want.
|
||||||
|
let want_444 = settings.enable_444
|
||||||
|
&& pf_client_core::video::hevc_444_hardware_decodable(vulkan.as_ref());
|
||||||
|
if settings.enable_444 && !want_444 {
|
||||||
|
// Loud, because the user turned a switch on and is not getting it. The
|
||||||
|
// alternative is what this replaces: the host grants 4:4:4, the decode ladder
|
||||||
|
// has no rung that can take it, and the session drops HEVC entirely.
|
||||||
|
tracing::warn!(
|
||||||
|
"Full chroma (4:4:4) requested but this device has no 4:4:4 HEVC decode — \
|
||||||
|
asking for 4:2:0 instead. Advertising it would cost the whole codec: 4:4:4 \
|
||||||
|
is granted on HEVC only, and there is no software HEVC decoder to fall back \
|
||||||
|
to (PyroWave carries 4:4:4 on any GPU, if the link can take it)."
|
||||||
|
);
|
||||||
|
}
|
||||||
SessionParams {
|
SessionParams {
|
||||||
host: addr,
|
host: addr,
|
||||||
port,
|
port,
|
||||||
@@ -356,30 +372,16 @@ mod session_main {
|
|||||||
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
// slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
||||||
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
|
// The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges
|
||||||
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
// on multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
||||||
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit only says
|
// 4:4:4 is opt-in and off by default (Settings "Full chroma"): the bit says
|
||||||
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
|
// "upgrade me if you can" — the host still gates on its own policy, its capturer,
|
||||||
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
|
// HEVC, and a real GPU 4:4:4 encode probe, and answers the resolved chroma in the
|
||||||
// Welcome BEFORE we build a decoder. Advertised whenever the user asks because
|
// Welcome BEFORE we build a decoder. It is now ALSO gated on this device being
|
||||||
// every path can DISPLAY it: the Vulkan presenter samples the 2-plane 4:4:4 pool
|
// able to decode 4:4:4 (`want_444`, computed above); the rule and its reasoning
|
||||||
// formats (hardware RExt decode where the driver offers it — NVIDIA today),
|
// live in `video::video_caps_for`, which is where they get tested.
|
||||||
// with the decoder ladder demoting on its own. No capability probe gates the
|
|
||||||
// bit — but note (M8) that the software rung below it is 4:2:0 8-bit ONLY and
|
|
||||||
// refuses anything else rather than mis-scaling it, so on a box whose hardware
|
|
||||||
// 4:4:4 decode fails the floor is a codec fallback, not a converted picture.
|
|
||||||
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
|
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
|
||||||
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
|
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
|
||||||
// frames actually took.
|
// frames actually took.
|
||||||
video_caps: punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
|
video_caps: pf_client_core::video::video_caps_for(settings.hdr_enabled, want_444),
|
||||||
| if settings.hdr_enabled {
|
|
||||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
| if settings.enable_444 {
|
|
||||||
punktfunk_core::quic::VIDEO_CAP_444
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
// This panel's HDR colour volume → the host's virtual-display EDID, so host
|
// This panel's HDR colour volume → the host's virtual-display EDID, so host
|
||||||
// apps tone-map to the real glass. Windows reads it from DXGI (the
|
// apps tone-map to the real glass. Windows reads it from DXGI (the
|
||||||
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
|
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
|
||||||
@@ -496,6 +498,12 @@ mod session_main {
|
|||||||
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=native-vaapi` still overrides the decoder
|
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=native-vaapi` still overrides the decoder
|
||||||
/// choice (the pre-M10 `vaapi` spelling reaches the same rung — it migrates, loudly).
|
/// choice (the pre-M10 `vaapi` spelling reaches the same rung — it migrates, loudly).
|
||||||
|
///
|
||||||
|
/// ⚠⚠ Called from the TOP of [`run`], ahead of the `--list-adapters` / `--probe-decode`
|
||||||
|
/// early exits — not merely "before `run_session` creates the instance". Those flags
|
||||||
|
/// create Vulkan instances of their own and RADV latches `RADV_PERFTEST` when its ICD
|
||||||
|
/// initialises, so a call placed after them leaves the triage tool describing a device
|
||||||
|
/// that cannot decode while the streaming path decodes on it.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn enable_radv_video_decode() {
|
fn enable_radv_video_decode() {
|
||||||
const TOKEN: &str = "video_decode";
|
const TOKEN: &str = "video_decode";
|
||||||
@@ -579,6 +587,23 @@ mod session_main {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
// Before ANY Vulkan call — and that includes the two probe flags below, which is the
|
||||||
|
// whole reason this sits at the top of `run` instead of beside the session setup it
|
||||||
|
// was written for. Make RADV expose its video-decode queue + extensions so the
|
||||||
|
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated
|
||||||
|
// RADV). Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||||
|
//
|
||||||
|
// ⚠⚠ It USED to sit after the `--list-adapters` / `--probe-decode` / `--list-audio` /
|
||||||
|
// `--pair` early exits, which meant the triage tool answered a DIFFERENT question from
|
||||||
|
// the one the streaming path asks. Measured on a Steam Deck (2026-08-08, canary
|
||||||
|
// `e22af40f`), same binary, back to back: bare `--probe-decode` printed `vulkan video
|
||||||
|
// decode: no`, `driver decode ops: none (0x0)`, `no queue family advertises
|
||||||
|
// VIDEO_DECODE`; the same call with `RADV_PERFTEST=video_decode` in the environment
|
||||||
|
// printed `YES` and `H.264, H.265, AV1, VP9`. The tool exists to be believed, so any
|
||||||
|
// Deck triage that consulted it reached the opposite of the truth.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
enable_radv_video_decode();
|
||||||
|
|
||||||
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
||||||
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
||||||
if arg_flag("--list-adapters") {
|
if arg_flag("--list-adapters") {
|
||||||
@@ -753,11 +778,8 @@ mod session_main {
|
|||||||
return headless_pair(&pin);
|
return headless_pair(&pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
// (The RADV video-decode opt-in that used to live here now runs at the very top of
|
||||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
// `run` — it has to precede the probe flags too, not just the session.)
|
||||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
enable_radv_video_decode();
|
|
||||||
|
|
||||||
// The Settings device picks → env, unless the user already forced one by hand:
|
// The Settings device picks → env, unless the user already forced one by hand:
|
||||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||||
|
|||||||
@@ -547,7 +547,8 @@ pub struct IddPushCapturer {
|
|||||||
_keepalive: Box<dyn Send>,
|
_keepalive: Box<dyn Send>,
|
||||||
}
|
}
|
||||||
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
|
||||||
// COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
|
// COM interfaces; the frame/cursor delivery closures own `Arc` clones of the control device and are
|
||||||
|
// `Send + Sync` on their own). It is
|
||||||
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
|
// created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
|
||||||
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
|
// `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
|
||||||
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
|
// only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
|
||||||
|
|||||||
@@ -1174,12 +1174,12 @@ pub struct Settings {
|
|||||||
/// mirrors the Apple client's "Show game library" toggle, default off.
|
/// mirrors the Apple client's "Show game library" toggle, default off.
|
||||||
pub library_enabled: bool,
|
pub library_enabled: bool,
|
||||||
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
/// Which colour family the gamepad UI's living backdrop drifts through — the shared
|
||||||
/// `ui_palette` key (`"violet"` = the brand default, then `tide`/`forest`/`ember`/
|
/// `ui_palette` key (`"violet"` = the brand default, then `oled`/`nebula`/`abyss`/
|
||||||
/// `rose`/`graphite`; see `pf-console-ui`'s palette table, and the Apple/Android
|
/// `ember`/`moss`/`graphite`, then the six pale fields; see `pf-console-ui`'s palette
|
||||||
/// clients' twins). Presentation only: nothing about a stream depends on it, which is
|
/// table, and the Apple/Android clients' twins). Presentation only: nothing about a
|
||||||
/// why it is a device preference and never part of a settings profile. An unknown
|
/// stream depends on it, which is why it is a device preference and never part of a
|
||||||
/// name reads as the default rather than erroring — a newer client may have shipped a
|
/// settings profile. An unknown name reads as the default rather than erroring — a
|
||||||
/// palette this binary doesn't know.
|
/// newer client may have shipped a palette this binary doesn't know.
|
||||||
#[serde(default = "default_ui_palette")]
|
#[serde(default = "default_ui_palette")]
|
||||||
pub ui_palette: String,
|
pub ui_palette: String,
|
||||||
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
|
||||||
|
|||||||
@@ -1531,6 +1531,85 @@ pub fn av1_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
|||||||
d3d11
|
d3d11
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Can this client actually DECODE 4:4:4 HEVC — the question `VIDEO_CAP_444` is a promise
|
||||||
|
/// about, and the one nothing asked until a Steam Deck lost HEVC over it.
|
||||||
|
///
|
||||||
|
/// The bit used to ride the "Full chroma" toggle alone, with a comment saying the software
|
||||||
|
/// rung was the floor underneath it. M8 removed that floor: there is no CPU HEVC decoder at
|
||||||
|
/// all ([`software_decodable_codecs`]), and the host grants 4:4:4 only on HEVC. So on a
|
||||||
|
/// device with no 4:4:4 decode the toggle did not cost crispness — it cost the whole codec.
|
||||||
|
/// The Welcome resolves the chroma before a decoder exists, the native Vulkan constructor
|
||||||
|
/// then refuses the shape, VAAPI refuses it too, and the session reconnects on H.264 with
|
||||||
|
/// "HEVC decoding failed on this device" (field report 2026-08-08, Deck / VanGogh).
|
||||||
|
///
|
||||||
|
/// ⭐ Answered from the VULKAN rung alone, and that is exact rather than approximate: it is
|
||||||
|
/// the only rung in this build that implements 4:4:4 at all. `pf_vaadec::profile_for` maps
|
||||||
|
/// only `chroma_format_idc == 1` and errors `UnsupportedShape` on 3; `pf_dxvadec`'s config
|
||||||
|
/// refuses "anything but 4:2:0" by construction; the CPU rung is 8-bit 4:2:0 only. So a
|
||||||
|
/// device whose Vulkan driver offers no 4:4:4 decode profile has no 4:4:4 path in this
|
||||||
|
/// client, whatever its silicon can do. (That is why an Intel box — whose hardware HAS done
|
||||||
|
/// HEVC 4:4:4 since Ice Lake — is still a `false` here: our DXVA/VAAPI rungs do not
|
||||||
|
/// implement it, so advertising it would be a lie about US, not about the GPU.)
|
||||||
|
///
|
||||||
|
/// ⚠ Both depths are required, not either: with HDR on, the host may resolve 4:4:4 **10-bit**,
|
||||||
|
/// and a device offering `YUV444_8` but not `YUV444_10` would land in exactly the hole this
|
||||||
|
/// closes. Asking for both costs one extra capability query and removes the case entirely.
|
||||||
|
///
|
||||||
|
/// ⚠ Deliberately NOT extended to `VIDEO_CAP_10BIT`/`VIDEO_CAP_HDR`, which are advertised
|
||||||
|
/// unprobed for the same reason this one was. The asymmetry is real: all three hardware
|
||||||
|
/// rungs implement 10-bit 4:2:0 (`profile_for` maps `(H265, 1, 10)` and `(Av1, 1, 10)`;
|
||||||
|
/// pf-dxvadec carries P010), so a Vulkan-only probe there would answer `false` on boxes
|
||||||
|
/// whose VAAPI/DXVA rung decodes 10-bit perfectly and would silently withdraw HDR from
|
||||||
|
/// them — a visible regression bought against a case that has never been observed. Gating
|
||||||
|
/// 10-bit honestly needs a libva/D3D11 probe, which this path cannot afford (same reason
|
||||||
|
/// [`av1_hardware_decodable`] does not consult VAAPI).
|
||||||
|
pub fn hevc_444_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||||||
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
|
{
|
||||||
|
vk.is_some_and(|v| {
|
||||||
|
crate::video_vk_native::hevc_shape_supported(v, CHROMA_444, 0)
|
||||||
|
&& crate::video_vk_native::hevc_shape_supported(v, CHROMA_444, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// No native Vulkan rung is compiled in off the two desktop OSes, so nothing here can
|
||||||
|
// decode 4:4:4 and the honest answer is a constant.
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
{
|
||||||
|
let _ = vk;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `chroma_format_idc` for 4:4:4 (H.265 7.4.3.2) — spelled once so the two depth probes
|
||||||
|
/// above and any future caller cannot disagree about the magic number.
|
||||||
|
const CHROMA_444: u8 = 3;
|
||||||
|
|
||||||
|
/// The desktop session's `video_caps` bitfield, as a pure function of the two user
|
||||||
|
/// switches that move it — so the rule can be tested without a GPU, a host or a Hello.
|
||||||
|
///
|
||||||
|
/// `want_444` is the "Full chroma" setting **already ANDed with this device's ability to
|
||||||
|
/// decode it** ([`hevc_444_hardware_decodable`]). Split that way on purpose: the caller
|
||||||
|
/// owns the expensive driver question and can log its own refusal with the user's setting
|
||||||
|
/// in hand, while the bit arithmetic — the part that was wrong — stays testable.
|
||||||
|
///
|
||||||
|
/// `MULTI_SLICE` is unconditional and is decoder truth for THIS embedder: every desktop
|
||||||
|
/// decode stack (Vulkan Video, D3D11VA, VAAPI, openh264/rav1d) handles AUs carrying
|
||||||
|
/// several slice NALs, so the host may keep its multi-slice low-latency default (§7 LN1).
|
||||||
|
/// ⚠ The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges on
|
||||||
|
/// multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
|
||||||
|
///
|
||||||
|
/// HDR off means 10-bit is not advertised either, so the host never upgrades depth.
|
||||||
|
pub fn video_caps_for(hdr_enabled: bool, want_444: bool) -> u8 {
|
||||||
|
let mut caps = punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE;
|
||||||
|
if hdr_enabled {
|
||||||
|
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
|
||||||
|
}
|
||||||
|
if want_444 {
|
||||||
|
caps |= punktfunk_core::quic::VIDEO_CAP_444;
|
||||||
|
}
|
||||||
|
caps
|
||||||
|
}
|
||||||
|
|
||||||
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
|
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
|
||||||
/// compute-feature probe, minus the codecs `decoder_pref` makes unreachable.
|
/// compute-feature probe, minus the codecs `decoder_pref` makes unreachable.
|
||||||
/// Advertisement-only: `resolve_codec` never auto-picks PyroWave — the session must also
|
/// Advertisement-only: `resolve_codec` never auto-picks PyroWave — the session must also
|
||||||
@@ -2701,6 +2780,53 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
|
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
|
||||||
|
|
||||||
|
/// The 4:4:4 advertisement is a PROMISE, and M8 removed the floor that used to make a
|
||||||
|
/// broken one survivable: there is no CPU HEVC decoder, and the host grants 4:4:4 on
|
||||||
|
/// HEVC only, so advertising it on a device that cannot decode it costs the entire
|
||||||
|
/// codec (field 2026-08-08, Steam Deck / VanGogh — HEVC fell back to H.264).
|
||||||
|
///
|
||||||
|
/// The device question needs a GPU; THIS is the half that does not, and it is the half
|
||||||
|
/// that was wrong — the bit used to ride `enable_444` alone.
|
||||||
|
#[test]
|
||||||
|
fn the_444_bit_needs_the_setting_and_a_device_that_can_decode_it() {
|
||||||
|
const V444: u8 = punktfunk_core::quic::VIDEO_CAP_444;
|
||||||
|
// The regression itself: setting on, device can't → the bit must NOT go out.
|
||||||
|
assert_eq!(
|
||||||
|
video_caps_for(true, false) & V444,
|
||||||
|
0,
|
||||||
|
"a 4:4:4 promise this device cannot keep costs HEVC entirely"
|
||||||
|
);
|
||||||
|
// ...and the feature still works where it can be honoured.
|
||||||
|
assert_ne!(video_caps_for(true, true) & V444, 0);
|
||||||
|
// Never advertised unasked, whatever the device can do.
|
||||||
|
assert_eq!(video_caps_for(true, false) & V444, 0);
|
||||||
|
assert_eq!(video_caps_for(false, false) & V444, 0);
|
||||||
|
|
||||||
|
// The 4:4:4 gate must not disturb the other two bits (10-bit/HDR is deliberately
|
||||||
|
// NOT probe-gated — see `hevc_444_hardware_decodable`'s docs for why).
|
||||||
|
const HDR_BITS: u8 =
|
||||||
|
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
|
||||||
|
for want_444 in [false, true] {
|
||||||
|
assert_eq!(video_caps_for(true, want_444) & HDR_BITS, HDR_BITS);
|
||||||
|
assert_eq!(video_caps_for(false, want_444) & HDR_BITS, 0);
|
||||||
|
assert_ne!(
|
||||||
|
video_caps_for(false, want_444) & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE,
|
||||||
|
0,
|
||||||
|
"MULTI_SLICE is unconditional for this embedder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No presenter Vulkan device ⇒ no 4:4:4, and that is an ANSWER rather than a missing
|
||||||
|
/// one: the native Vulkan rung is the only one in this build that implements 4:4:4 at
|
||||||
|
/// all (`pf_vaadec::profile_for` errors on `chroma_format_idc == 3`, pf-dxvadec refuses
|
||||||
|
/// anything but 4:2:0, the CPU rung is 8-bit 4:2:0). The `Some` arm needs real hardware
|
||||||
|
/// and lives in the GPU suites.
|
||||||
|
#[test]
|
||||||
|
fn no_vulkan_device_means_no_444_promise() {
|
||||||
|
assert!(!hevc_444_hardware_decodable(None));
|
||||||
|
}
|
||||||
|
|
||||||
/// The reconnect rule, as the invariant it is: an exhausted codec must come back as
|
/// The reconnect rule, as the invariant it is: an exhausted codec must come back as
|
||||||
/// one this client can decode ALL THE WAY DOWN, and must never come back as itself.
|
/// one this client can decode ALL THE WAY DOWN, and must never come back as itself.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -216,6 +216,70 @@ fn submit_queues_collide(graphics_qf: u32, decode_qf: u32) -> bool {
|
|||||||
graphics_qf == decode_qf
|
graphics_qf == decode_qf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The queue lock this device's decode lane submits under. One function so the
|
||||||
|
/// pre-session shape probe ([`hevc_shape_supported`]) and the real decoder cannot pick
|
||||||
|
/// different serialization for the same device.
|
||||||
|
fn queue_lock_for(vk: &VulkanDecodeDevice) -> Box<dyn pf_vkdecode::QueueLock> {
|
||||||
|
if submit_queues_collide(vk.graphics_qf, vk.decode_qf) {
|
||||||
|
Box::new(NativeQueueLock::Shared(vk.queue_lock.clone()))
|
||||||
|
} else {
|
||||||
|
Box::new(NativeQueueLock::Uncontended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The presenter's handles in pf-vkdecode's shape. Same reason as [`queue_lock_for`]:
|
||||||
|
/// the probe must ask about the DEVICE THE SESSION WOULD USE, not a re-derived one.
|
||||||
|
fn device_handles(vk: &VulkanDecodeDevice) -> DeviceHandles {
|
||||||
|
DeviceHandles {
|
||||||
|
get_instance_proc_addr: vk.get_instance_proc_addr,
|
||||||
|
instance: vk.instance,
|
||||||
|
physical_device: vk.physical_device,
|
||||||
|
device: vk.device,
|
||||||
|
decode_qf: vk.decode_qf,
|
||||||
|
decode_queue_index: DECODE_QUEUE_INDEX,
|
||||||
|
graphics_qf: vk.graphics_qf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this device hardware-decode HEVC at the given picture shape? Asked BEFORE the
|
||||||
|
/// Hello, so the client never advertises a shape it would have to refuse a session over.
|
||||||
|
///
|
||||||
|
/// This is the same question, through the same code, that
|
||||||
|
/// [`NativeVulkanDecoder::new`]'s H.265 arm asks at construction — `VkH265Decoder::new`
|
||||||
|
/// then `probe_stream_support` — deliberately, so an advertisement and the rung that has
|
||||||
|
/// to honour it cannot disagree. It creates and drops a decoder object; that costs a
|
||||||
|
/// handful of driver capability queries and no session, no images and no submits.
|
||||||
|
///
|
||||||
|
/// `false` when the presenter has no Vulkan Video decode at all, which for 4:4:4 is the
|
||||||
|
/// right answer rather than a missing one — see
|
||||||
|
/// [`crate::video::hevc_444_hardware_decodable`] for why no other rung can be asked.
|
||||||
|
pub(crate) fn hevc_shape_supported(
|
||||||
|
vk: &VulkanDecodeDevice,
|
||||||
|
chroma_format_idc: u8,
|
||||||
|
bit_depth_luma_minus8: u8,
|
||||||
|
) -> bool {
|
||||||
|
if !vk.video_decode {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The device-independent half first: a shape pf-vkdecode has no picture format for
|
||||||
|
// needs no driver to refuse it (and `probe_stream_support` would only re-derive it).
|
||||||
|
if pf_vkdecode::output_format_for(chroma_format_idc, bit_depth_luma_minus8).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// SAFETY: the `DeviceHandles` contract exactly as `NativeVulkanDecoder::new` states
|
||||||
|
// it — these are the presenter's live instance/device, which outlive this call by
|
||||||
|
// construction (the presenter owns them for the whole process, and this runs on its
|
||||||
|
// thread while building the session's Hello). The decoder is dropped before return,
|
||||||
|
// so nothing outlives the borrow.
|
||||||
|
let dec = unsafe { pf_vkdecode::VkH265Decoder::new(&device_handles(vk), queue_lock_for(vk)) };
|
||||||
|
match dec {
|
||||||
|
Ok(d) => d
|
||||||
|
.probe_stream_support(chroma_format_idc, bit_depth_luma_minus8)
|
||||||
|
.is_ok(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// [`pf_vkdecode::QueueLock`] over the device's shared [`crate::video::QueueLock`] —
|
/// [`pf_vkdecode::QueueLock`] over the device's shared [`crate::video::QueueLock`] —
|
||||||
/// or over nothing, when the decode queue provably has no other submitter (see the
|
/// or over nothing, when the decode queue provably has no other submitter (see the
|
||||||
/// module doc's queue-lock section).
|
/// module doc's queue-lock section).
|
||||||
@@ -934,21 +998,8 @@ impl NativeVulkanDecoder {
|
|||||||
if !vk.video_decode {
|
if !vk.video_decode {
|
||||||
bail!("presenter device lacks Vulkan Video decode");
|
bail!("presenter device lacks Vulkan Video decode");
|
||||||
}
|
}
|
||||||
let lock: Box<dyn pf_vkdecode::QueueLock> =
|
let lock = queue_lock_for(vk);
|
||||||
if submit_queues_collide(vk.graphics_qf, vk.decode_qf) {
|
let handles = device_handles(vk);
|
||||||
Box::new(NativeQueueLock::Shared(vk.queue_lock.clone()))
|
|
||||||
} else {
|
|
||||||
Box::new(NativeQueueLock::Uncontended)
|
|
||||||
};
|
|
||||||
let handles = DeviceHandles {
|
|
||||||
get_instance_proc_addr: vk.get_instance_proc_addr,
|
|
||||||
instance: vk.instance,
|
|
||||||
physical_device: vk.physical_device,
|
|
||||||
device: vk.device,
|
|
||||||
decode_qf: vk.decode_qf,
|
|
||||||
decode_queue_index: DECODE_QUEUE_INDEX,
|
|
||||||
graphics_qf: vk.graphics_qf,
|
|
||||||
};
|
|
||||||
// The `DeviceHandles` caller contract, held for the decoder's whole lifetime
|
// The `DeviceHandles` caller contract, held for the decoder's whole lifetime
|
||||||
// and identical for both arms (it is the HANDLES' contract, not the codec's):
|
// and identical for both arms (it is the HANDLES' contract, not the codec's):
|
||||||
// the handles are the presenter's live instance/device, which outlives every
|
// the handles are the presenter's live instance/device, which outlives every
|
||||||
|
|||||||
@@ -246,17 +246,34 @@ const CELL_RAMP: [f64; 16] = [
|
|||||||
-0.10, 0.08, -0.06, 0.12,
|
-0.10, 0.08, -0.06, 0.12,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones.
|
/// The thirteen shipped palettes: the brand default, six more dark fields, then six pale ones.
|
||||||
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
|
/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction.
|
||||||
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
|
/// Adding one here adds it to every console settings screen; the Apple and Android tables must
|
||||||
/// gain the same entry to keep the `ui_palette` key portable.
|
/// gain the same entry to keep the `ui_palette` key portable.
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
pub const PALETTES: [Palette; 12] = [
|
pub const PALETTES: [Palette; 13] = [
|
||||||
// --- dark fields (white ink) ---
|
// --- dark fields (white ink) ---
|
||||||
Palette {
|
Palette {
|
||||||
id: "violet", name: "Violet", stops: None,
|
id: "violet", name: "Violet", stops: None,
|
||||||
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
|
ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false,
|
||||||
},
|
},
|
||||||
|
Palette {
|
||||||
|
// For OLED and AMOLED panels, where a black pixel is a pixel switched off — no glow,
|
||||||
|
// no power. The ramp's first two stops are literally (0,0,0), so the whole shaded half
|
||||||
|
// of the field is genuinely off rather than "very dark grey", and the ground is pure
|
||||||
|
// black too: the calm mix on the form screens lifts toward nothing, so settings and
|
||||||
|
// pairing sit on an unlit panel. What is left is a faint indigo→violet ember in the
|
||||||
|
// bright corner, dim enough to stay under a tenth of the other dark fields' mean
|
||||||
|
// luminance while keeping the backdrop a field with somewhere to go rather than a
|
||||||
|
// dead rectangle. The accent stays the brand violet — focus has to be findable on
|
||||||
|
// black.
|
||||||
|
id: "oled", name: "OLED",
|
||||||
|
stops: Some(&[
|
||||||
|
(0.000, 0.000, 0.000), (0.000, 0.000, 0.000), (0.010, 0.020, 0.100),
|
||||||
|
(0.045, 0.016, 0.115), (0.120, 0.024, 0.130),
|
||||||
|
]),
|
||||||
|
ground: (0.0, 0.0, 0.0), accent: (0.525, 0.471, 0.961), light: false,
|
||||||
|
},
|
||||||
Palette {
|
Palette {
|
||||||
// Deep indigo climbing through violet into a hot magenta.
|
// Deep indigo climbing through violet into a hot magenta.
|
||||||
id: "nebula", name: "Nebula",
|
id: "nebula", name: "Nebula",
|
||||||
@@ -857,7 +874,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids,
|
ids,
|
||||||
[
|
[
|
||||||
"violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
"violet", "oled", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset",
|
||||||
"bloom", "dawn", "mint", "opal",
|
"bloom", "dawn", "mint", "opal",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -867,7 +884,36 @@ mod tests {
|
|||||||
.position(|p| p.light)
|
.position(|p| p.light)
|
||||||
.expect("some are light");
|
.expect("some are light");
|
||||||
assert!(PALETTES[first_light..].iter().all(|p| p.light));
|
assert!(PALETTES[first_light..].iter().all(|p| p.light));
|
||||||
assert_eq!(first_light, 6);
|
assert_eq!(first_light, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OLED is the one palette whose selling point is measurable: it has to be genuinely
|
||||||
|
/// black, not merely the darkest of the dark fields. Pure black corners, a mean well
|
||||||
|
/// under every other field's, and a ground that lifts to nothing on the form screens.
|
||||||
|
#[test]
|
||||||
|
fn oled_is_actually_black() {
|
||||||
|
let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2;
|
||||||
|
let oled = palette("oled");
|
||||||
|
assert_eq!(
|
||||||
|
oled.ground,
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
"the calm lift must be nothing"
|
||||||
|
);
|
||||||
|
let cells = oled.mesh_colors();
|
||||||
|
assert!(
|
||||||
|
cells.iter().filter(|c| luma(**c) == 0.0).count() >= 3,
|
||||||
|
"the shaded corner has to be switched off, not dimmed"
|
||||||
|
);
|
||||||
|
let mean = cells.iter().map(|c| luma(*c)).sum::<f64>() / 16.0;
|
||||||
|
let darkest_other = PALETTES
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.id != "oled")
|
||||||
|
.map(|p| p.mesh_colors().iter().map(|c| luma(*c)).sum::<f64>() / 16.0)
|
||||||
|
.fold(f64::MAX, f64::min);
|
||||||
|
assert!(
|
||||||
|
mean < darkest_other / 2.0,
|
||||||
|
"oled means {mean:.3}, only half a stop under {darkest_other:.3}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
|
/// Every colour a palette produces stays in gamut, and a pale palette really is pale —
|
||||||
|
|||||||
@@ -258,11 +258,17 @@ impl SettingsScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The rows of the CURRENT tab. Profiles is built from the catalog: one row per
|
/// The rows of the CURRENT tab, minus any whose setting has nothing to act on (see
|
||||||
/// profile, or the explainer placeholder while there are none.
|
/// [`row_applies`]). Profiles is built from the catalog: one row per profile, or the
|
||||||
fn row_ids(&self) -> Vec<RowId> {
|
/// explainer placeholder while there are none.
|
||||||
|
fn row_ids(&self, ctx: &Ctx) -> Vec<RowId> {
|
||||||
if self.tab != PROFILES_TAB {
|
if self.tab != PROFILES_TAB {
|
||||||
return TABS[self.tab].1.to_vec();
|
return TABS[self.tab]
|
||||||
|
.1
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|id| row_applies(*id, ctx.settings))
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
if self.profiles.is_empty() {
|
if self.profiles.is_empty() {
|
||||||
vec![RowId::NoProfiles]
|
vec![RowId::NoProfiles]
|
||||||
@@ -271,6 +277,16 @@ impl SettingsScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull the cursor back onto the list. Every tab but Profiles used to be a fixed length,
|
||||||
|
/// so this only mattered on entry ([`show_tab`]); the smoothness buffer's row now comes
|
||||||
|
/// and goes, and another writer (a desktop shell, a session's match-window persist) can
|
||||||
|
/// take it away between frames while this screen is open.
|
||||||
|
fn clamp_cursor(&mut self, len: usize) {
|
||||||
|
if self.list.cursor >= len {
|
||||||
|
self.list.jump_to(len.saturating_sub(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn tab_for_test(&self) -> usize {
|
pub(crate) fn tab_for_test(&self) -> usize {
|
||||||
self.tab
|
self.tab
|
||||||
@@ -278,21 +294,22 @@ impl SettingsScreen {
|
|||||||
|
|
||||||
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
|
/// L1/R1 (and Tab/PgUp/PgDn) — move one tab, wrapping (the strip is a ring, like A's
|
||||||
/// value cycle), keeping each tab's own cursor.
|
/// value cycle), keeping each tab's own cursor.
|
||||||
fn switch_tab(&mut self, delta: i32) -> Option<MenuPulse> {
|
fn switch_tab(&mut self, delta: i32, ctx: &Ctx) -> Option<MenuPulse> {
|
||||||
let n = TABS.len() as i32;
|
let n = TABS.len() as i32;
|
||||||
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize)
|
self.show_tab((self.tab as i32 + delta).rem_euclid(n) as usize, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
|
/// Show `tab`, parking the cursor the outgoing tab was on. Also the pointer's path in:
|
||||||
/// a press on a pill names a tab outright rather than a direction to step in.
|
/// a press on a pill names a tab outright rather than a direction to step in.
|
||||||
fn show_tab(&mut self, tab: usize) -> Option<MenuPulse> {
|
fn show_tab(&mut self, tab: usize, ctx: &Ctx) -> Option<MenuPulse> {
|
||||||
if tab >= TABS.len() {
|
if tab >= TABS.len() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.tab_cursors[self.tab] = self.list.cursor;
|
self.tab_cursors[self.tab] = self.list.cursor;
|
||||||
self.tab = tab;
|
self.tab = tab;
|
||||||
// Clamp the remembered cursor: the Profiles tab's length follows the catalog.
|
// Clamp the remembered cursor: the Profiles tab's length follows the catalog, and
|
||||||
let len = self.row_ids().len();
|
// Video's follows whether the smoothness buffer is offered.
|
||||||
|
let len = self.row_ids(ctx).len();
|
||||||
self.list
|
self.list
|
||||||
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
|
.jump_to(self.tab_cursors[self.tab].min(len.saturating_sub(1)));
|
||||||
Some(MenuPulse::Move)
|
Some(MenuPulse::Move)
|
||||||
@@ -302,10 +319,11 @@ impl SettingsScreen {
|
|||||||
/// there is never meant for a row.
|
/// there is never meant for a row.
|
||||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||||
if let Some(tab) = self.strip.pointer(p) {
|
if let Some(tab) = self.strip.pointer(p) {
|
||||||
self.show_tab(tab);
|
self.show_tab(tab, ctx);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let ids = self.row_ids();
|
let ids = self.row_ids(ctx);
|
||||||
|
self.clamp_cursor(ids.len());
|
||||||
let (msg, pulse) = self.list.pointer(p, ids.len());
|
let (msg, pulse) = self.list.pointer(p, ids.len());
|
||||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||||
return false;
|
return false;
|
||||||
@@ -325,11 +343,12 @@ impl SettingsScreen {
|
|||||||
fx.pop();
|
fx.pop();
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
MenuEvent::JumpBack => return self.switch_tab(-1),
|
MenuEvent::JumpBack => return self.switch_tab(-1, ctx),
|
||||||
MenuEvent::JumpForward => return self.switch_tab(1),
|
MenuEvent::JumpForward => return self.switch_tab(1, ctx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
let ids = self.row_ids();
|
let ids = self.row_ids(ctx);
|
||||||
|
self.clamp_cursor(ids.len());
|
||||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||||
self.apply_row(msg, pulse, &ids, ctx, fx)
|
self.apply_row(msg, pulse, &ids, ctx, fx)
|
||||||
}
|
}
|
||||||
@@ -344,8 +363,14 @@ impl SettingsScreen {
|
|||||||
ctx: &mut Ctx,
|
ctx: &mut Ctx,
|
||||||
fx: &mut Outbox,
|
fx: &mut Outbox,
|
||||||
) -> Option<MenuPulse> {
|
) -> Option<MenuPulse> {
|
||||||
|
// A cursor with no row under it can only mean the list shrank between the clamp above
|
||||||
|
// and here, which nothing does today — but indexing on the assumption would turn that
|
||||||
|
// into a panic in a shipping console rather than a dropped keypress.
|
||||||
|
let Some(&focused) = ids.get(self.list.cursor) else {
|
||||||
|
return pulse;
|
||||||
|
};
|
||||||
// The Profiles rows navigate instead of editing the settings file.
|
// The Profiles rows navigate instead of editing the settings file.
|
||||||
match ids[self.list.cursor] {
|
match focused {
|
||||||
RowId::Profile(i) => {
|
RowId::Profile(i) => {
|
||||||
return match msg {
|
return match msg {
|
||||||
ListMsg::Activate => {
|
ListMsg::Activate => {
|
||||||
@@ -378,7 +403,7 @@ impl SettingsScreen {
|
|||||||
}
|
}
|
||||||
match msg {
|
match msg {
|
||||||
ListMsg::Adjust(delta) => {
|
ListMsg::Adjust(delta) => {
|
||||||
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
|
let changed = adjust(focused, delta, false, ctx);
|
||||||
if changed {
|
if changed {
|
||||||
ctx.settings.save();
|
ctx.settings.save();
|
||||||
Some(MenuPulse::Move)
|
Some(MenuPulse::Move)
|
||||||
@@ -388,7 +413,7 @@ impl SettingsScreen {
|
|||||||
}
|
}
|
||||||
ListMsg::Activate => {
|
ListMsg::Activate => {
|
||||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||||
if adjust(ids[self.list.cursor], 1, true, ctx) {
|
if adjust(focused, 1, true, ctx) {
|
||||||
ctx.settings.save();
|
ctx.settings.save();
|
||||||
}
|
}
|
||||||
pulse
|
pulse
|
||||||
@@ -397,8 +422,8 @@ impl SettingsScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||||
let ids = self.row_ids();
|
let ids = self.row_ids(ctx);
|
||||||
// The shoulders always change section, so that hint leads on every row.
|
// The shoulders always change section, so that hint leads on every row.
|
||||||
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
let mut hints = vec![Hint::new(HintKey::Shoulders, "Section")];
|
||||||
hints.extend(match ids.get(self.list.cursor) {
|
hints.extend(match ids.get(self.list.cursor) {
|
||||||
@@ -445,7 +470,8 @@ impl SettingsScreen {
|
|||||||
rect.right,
|
rect.right,
|
||||||
rect.bottom - detail_h as f32,
|
rect.bottom - detail_h as f32,
|
||||||
);
|
);
|
||||||
let ids = self.row_ids();
|
let ids = self.row_ids(ctx);
|
||||||
|
self.clamp_cursor(ids.len());
|
||||||
let rows: Vec<RowSpec> = ids
|
let rows: Vec<RowSpec> = ids
|
||||||
.iter()
|
.iter()
|
||||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||||
@@ -466,6 +492,24 @@ impl SettingsScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a row is OFFERED at all, as opposed to offered-but-inert.
|
||||||
|
///
|
||||||
|
/// The two are a real distinction. Echo cancellation and the pad rows follow a switch the user
|
||||||
|
/// can see a line or two above them, so dimming them shows the relationship — dropping them
|
||||||
|
/// would just make settings appear and disappear as the switch flips. The smoothness buffer is
|
||||||
|
/// different: it is not a sub-setting of a switch, it is a knob on ONE of two intents, and
|
||||||
|
/// under Lowest latency it names a quantity that doesn't exist. Every other settings surface —
|
||||||
|
/// the GTK and WinUI shells, the Apple touch/tvOS screens, the Android touch screen — hides it
|
||||||
|
/// there. This screen was the lone exception because its row list was fixed; it is rebuilt from
|
||||||
|
/// this filter each frame now, and the row it drops sits directly BELOW the row that drops it,
|
||||||
|
/// so the cursor is never under anything that moves.
|
||||||
|
fn row_applies(id: RowId, s: &pf_client_core::trust::Settings) -> bool {
|
||||||
|
match id {
|
||||||
|
RowId::SmoothBuffer => s.present_priority == "smooth",
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||||
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
||||||
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
||||||
@@ -497,18 +541,17 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
let s = &ctx.settings;
|
let s = &ctx.settings;
|
||||||
// Several rows follow another: echo cancellation only means anything while the mic
|
// Two rows follow a switch a line or two above them: echo cancellation only means
|
||||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
// anything while the mic streams, and the pad rows only while any controller is
|
||||||
// smoothness buffer only while that intent is chosen. All go dim and inert otherwise
|
// forwarded at all. Both go dim and inert otherwise — the same relationship the desktop
|
||||||
// — the same relationship the desktop shells draw by greying a row out (they hide the
|
// shells draw by greying a row out, and dimming (not dropping) is what shows the
|
||||||
// buffer row entirely; a fixed row list can't, and a row that vanished mid-list would
|
// relationship. The smoothness buffer used to be listed here too; it is dropped from the
|
||||||
// move everything under the cursor).
|
// list instead now — see [`row_applies`] for why that one is different.
|
||||||
let enabled = match id {
|
let enabled = match id {
|
||||||
RowId::EchoCancel => s.mic_enabled,
|
RowId::EchoCancel => s.mic_enabled,
|
||||||
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => {
|
||||||
s.gamepad_forwarding
|
s.gamepad_forwarding
|
||||||
}
|
}
|
||||||
RowId::SmoothBuffer => s.present_priority == "smooth",
|
|
||||||
_ => true,
|
_ => true,
|
||||||
};
|
};
|
||||||
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
let (header, label, value): (Option<&'static str>, &str, String) = match id {
|
||||||
@@ -848,7 +891,10 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
|||||||
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
step_option(cur, PRESENT_PRIORITIES.len(), delta, wrap)
|
||||||
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
.map(|i| s.present_priority = PRESENT_PRIORITIES[i].0.to_string())
|
||||||
}
|
}
|
||||||
// Inert unless smoothness is chosen — a boundary thud, matching the dimmed row.
|
// Under Lowest latency the row isn't offered at all ([`row_applies`]), so this branch
|
||||||
|
// is only reachable if another writer flipped the intent between the frame that built
|
||||||
|
// the list and the keypress that lands here — a boundary thud, not a stored value
|
||||||
|
// nothing will read.
|
||||||
RowId::SmoothBuffer => {
|
RowId::SmoothBuffer => {
|
||||||
if s.present_priority == "smooth" {
|
if s.present_priority == "smooth" {
|
||||||
let cur = SMOOTH_BUFFERS
|
let cur = SMOOTH_BUFFERS
|
||||||
@@ -1093,9 +1139,6 @@ mod tests {
|
|||||||
fake_home();
|
fake_home();
|
||||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||||
rendered(&mut s);
|
rendered(&mut s);
|
||||||
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
|
||||||
// window: one field, one unambiguous effect to assert on.
|
|
||||||
assert_eq!(s.row_ids()[0], RowId::Resolution);
|
|
||||||
let first = s.list.row_rect(0).expect("the list drew its rows");
|
let first = s.list.row_rect(0).expect("the list drew its rows");
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
|
settings.save(); // seat the fake HOME's file — `apply_row` rebases on it
|
||||||
@@ -1109,6 +1152,9 @@ mod tests {
|
|||||||
device_name: "t",
|
device_name: "t",
|
||||||
t: 0.0,
|
t: 0.0,
|
||||||
};
|
};
|
||||||
|
// Row 0 of the leading tab is Resolution, whose first step is Native → Match
|
||||||
|
// window: one field, one unambiguous effect to assert on.
|
||||||
|
assert_eq!(s.row_ids(&ctx)[0], RowId::Resolution);
|
||||||
let mut fx = Outbox::default();
|
let mut fx = Outbox::default();
|
||||||
assert!(!ctx.settings.match_window);
|
assert!(!ctx.settings.match_window);
|
||||||
assert!(s.pointer(press(first), &mut ctx, &mut fx));
|
assert!(s.pointer(press(first), &mut ctx, &mut fx));
|
||||||
@@ -1232,13 +1278,12 @@ mod tests {
|
|||||||
assert!(ctx.settings.echo_cancel);
|
assert!(ctx.settings.echo_cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The smoothness buffer follows the presentation intent, exactly as echo cancellation
|
/// The smoothness buffer is OFFERED only under Smoothness — under Lowest latency it names
|
||||||
/// follows the mic: dimmed and inert under Lowest latency (where holding frames means
|
/// a quantity that doesn't exist, so the row is gone from the Video tab rather than sitting
|
||||||
/// nothing), live under Smoothness. The desktop shells hide the row instead; a fixed
|
/// there dimmed. This is what the GTK and WinUI shells and the Apple/Android screens have
|
||||||
/// row list dims it, because a row vanishing mid-list would shift everything under the
|
/// always done; this screen was the exception until its row list stopped being fixed.
|
||||||
/// cursor.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn smoothness_buffer_follows_the_intent() {
|
fn smoothness_buffer_is_offered_only_under_smoothness() {
|
||||||
let (mut settings, pads) = ctx_parts();
|
let (mut settings, pads) = ctx_parts();
|
||||||
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
assert_eq!(settings.present_priority, "latency", "the shipped default");
|
||||||
let library = crate::library::LibraryShared::default();
|
let library = crate::library::LibraryShared::default();
|
||||||
@@ -1251,24 +1296,93 @@ mod tests {
|
|||||||
device_name: "t",
|
device_name: "t",
|
||||||
t: 0.0,
|
t: 0.0,
|
||||||
};
|
};
|
||||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||||
|
s.tab = TABS
|
||||||
|
.iter()
|
||||||
|
.position(|(name, _)| *name == "Video")
|
||||||
|
.expect("the Video tab");
|
||||||
|
|
||||||
|
let video = s.row_ids(&ctx);
|
||||||
|
assert!(
|
||||||
|
!video.contains(&RowId::SmoothBuffer),
|
||||||
|
"latency hides the buffer row: {video:?}"
|
||||||
|
);
|
||||||
|
assert!(video.contains(&RowId::PresentPriority), "the intent stays");
|
||||||
|
// Even reached out of band it writes nothing — the list it came from is a frame old.
|
||||||
assert!(
|
assert!(
|
||||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||||
"latency intent = thud"
|
"latency intent = thud"
|
||||||
);
|
);
|
||||||
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
assert_eq!(ctx.settings.smooth_buffer, 0, "and nothing was written");
|
||||||
|
|
||||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
// Stepping the intent to Smoothness brings the row into the list, directly under it.
|
||||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||||
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
let video = s.row_ids(&ctx);
|
||||||
|
let intent = video
|
||||||
|
.iter()
|
||||||
|
.position(|id| *id == RowId::PresentPriority)
|
||||||
|
.expect("the intent row");
|
||||||
|
assert_eq!(
|
||||||
|
video.get(intent + 1),
|
||||||
|
Some(&RowId::SmoothBuffer),
|
||||||
|
"the row that comes and goes sits BELOW the row that decides it, so the cursor \
|
||||||
|
never has anything move out from under it"
|
||||||
|
);
|
||||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||||
|
|
||||||
// The intent wraps back and the row goes inert again.
|
// The intent wraps back and the row leaves again — with the cursor parked on the
|
||||||
|
// intent row, which is where a user who just stepped it necessarily is.
|
||||||
|
s.list.cursor = intent;
|
||||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||||
assert_eq!(ctx.settings.present_priority, "latency");
|
assert_eq!(ctx.settings.present_priority, "latency");
|
||||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
let video = s.row_ids(&ctx);
|
||||||
|
assert!(!video.contains(&RowId::SmoothBuffer));
|
||||||
|
assert_eq!(
|
||||||
|
video.get(s.list.cursor),
|
||||||
|
Some(&RowId::PresentPriority),
|
||||||
|
"the cursor is still on the row the user was stepping"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cursor parked past the end of a list that shrank underneath it is pulled back rather
|
||||||
|
/// than indexed with — the console must not panic because another writer changed the
|
||||||
|
/// presentation intent while its settings screen was open.
|
||||||
|
#[test]
|
||||||
|
fn a_shrinking_list_pulls_the_cursor_back() {
|
||||||
|
// `apply_row` rebases on the FILE before acting, so this has to be seated — and
|
||||||
|
// seated with the SHRUNKEN list's intent, which is the state being tested.
|
||||||
|
fake_home();
|
||||||
|
let (mut settings, pads) = ctx_parts();
|
||||||
|
settings.present_priority = "latency".into();
|
||||||
|
settings.save();
|
||||||
|
settings.present_priority = "smooth".into();
|
||||||
|
let library = crate::library::LibraryShared::default();
|
||||||
|
let mut ctx = Ctx {
|
||||||
|
hosts: &[],
|
||||||
|
library: &library,
|
||||||
|
settings: &mut settings,
|
||||||
|
pads: &pads,
|
||||||
|
deck: false,
|
||||||
|
device_name: "t",
|
||||||
|
t: 0.0,
|
||||||
|
};
|
||||||
|
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||||
|
s.tab = TABS
|
||||||
|
.iter()
|
||||||
|
.position(|(name, _)| *name == "Video")
|
||||||
|
.expect("the Video tab");
|
||||||
|
// Park on the last row while the buffer row is still there…
|
||||||
|
s.list.cursor = s.row_ids(&ctx).len() - 1;
|
||||||
|
let parked = s.list.cursor;
|
||||||
|
// …then take it away behind the screen's back, as a desktop shell would.
|
||||||
|
ctx.settings.present_priority = "latency".into();
|
||||||
|
let mut fx = Outbox::default();
|
||||||
|
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||||
|
assert!(pulse.is_some(), "the press was routed, not dropped");
|
||||||
|
assert!(s.list.cursor < parked, "the cursor came back onto the list");
|
||||||
|
assert!(fx.nav.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1392,7 +1506,7 @@ mod tests {
|
|||||||
("p2".into(), "Game".into()),
|
("p2".into(), "Game".into()),
|
||||||
]);
|
]);
|
||||||
s.tab = PROFILES_TAB;
|
s.tab = PROFILES_TAB;
|
||||||
let ids = s.row_ids();
|
let ids = s.row_ids(&ctx);
|
||||||
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
|
assert_eq!(ids, vec![RowId::Profile(0), RowId::Profile(1)]);
|
||||||
|
|
||||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||||
@@ -1438,7 +1552,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||||
s.tab = PROFILES_TAB;
|
s.tab = PROFILES_TAB;
|
||||||
let ids = s.row_ids();
|
let ids = s.row_ids(&ctx);
|
||||||
assert_eq!(ids, vec![RowId::NoProfiles]);
|
assert_eq!(ids, vec![RowId::NoProfiles]);
|
||||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||||
assert!(!spec.enabled);
|
assert!(!spec.enabled);
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ fn dump_console_screens() {
|
|||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
s.handle_menu(MenuEvent::JumpForward);
|
s.handle_menu(MenuEvent::JumpForward);
|
||||||
}
|
}
|
||||||
for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] {
|
for id in ["violet", "oled", "ember", "abyss", "holo", "sunset", "mint"] {
|
||||||
s.settings.ui_palette = id.to_string();
|
s.settings.ui_palette = id.to_string();
|
||||||
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
|
dump(&mut s, 40, 8, &format!("03-settings-{id}"), true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,18 @@ pub struct HostConfig {
|
|||||||
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
|
/// encode, so this is the knob that decides how bright "white" looks on the client's panel.
|
||||||
/// `None` = leave gamescope's own default.
|
/// `None` = leave gamescope's own default.
|
||||||
pub gamescope_sdr_nits: Option<u32>,
|
pub gamescope_sdr_nits: Option<u32>,
|
||||||
|
/// `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` — extra refresh rates (Hz, comma-separated) a gamescope
|
||||||
|
/// session offers its clients on top of the one it runs at, e.g. `60,90,120`.
|
||||||
|
///
|
||||||
|
/// A headless gamescope has no EDID, so it cannot work out what else its display could run at:
|
||||||
|
/// on a stock build it advertises exactly ONE rate and Steam's in-session display settings show
|
||||||
|
/// a single entry. Our `+pfhdr3` build takes this list (`--custom-refresh-rates`) and publishes
|
||||||
|
/// it, which is what puts real choices in that menu. The session's own rate is always included
|
||||||
|
/// whatever is set here, so this can only ever ADD options.
|
||||||
|
///
|
||||||
|
/// Empty (the default) = advertise only the negotiated rate. Ignored on a stock gamescope,
|
||||||
|
/// which has no flag to take it.
|
||||||
|
pub gamescope_refresh_rates: Vec<u32>,
|
||||||
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
/// `PUNKTFUNK_RECOVER_SESSION_CMD` — operator hook fired (debounced) when a client connects while NO
|
||||||
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
/// graphical session is live for this uid: the state a compositor crash leaves behind (gnome-shell
|
||||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||||
@@ -379,6 +391,12 @@ impl HostConfig {
|
|||||||
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
|
gamescope_sdr_nits: val("PUNKTFUNK_GAMESCOPE_SDR_NITS")
|
||||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||||
.filter(|n| (1..=10_000).contains(n)),
|
.filter(|n| (1..=10_000).contains(n)),
|
||||||
|
// Unparseable entries are DROPPED rather than failing the host: this only ever widens a
|
||||||
|
// menu, and the session's own rate is added back unconditionally, so the worst a typo
|
||||||
|
// can cost is the extra option the operator wanted — never the session.
|
||||||
|
gamescope_refresh_rates: parse_refresh_rates(
|
||||||
|
val("PUNKTFUNK_GAMESCOPE_REFRESH_RATES").as_deref(),
|
||||||
|
),
|
||||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||||
.filter(|s| !s.trim().is_empty()),
|
.filter(|s| !s.trim().is_empty()),
|
||||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||||
@@ -397,6 +415,20 @@ impl HostConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `"60, 90,120"` → `[60, 90, 120]`, sorted and deduped. Junk entries and out-of-range rates are
|
||||||
|
/// skipped rather than rejected wholesale — see the call site for why. Pure + unit-tested.
|
||||||
|
fn parse_refresh_rates(raw: Option<&str>) -> Vec<u32> {
|
||||||
|
let mut out: Vec<u32> = raw
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| s.trim().parse::<u32>().ok())
|
||||||
|
.filter(|&hz| (1..=1000).contains(&hz))
|
||||||
|
.collect();
|
||||||
|
out.sort_unstable();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
impl HostConfig {
|
impl HostConfig {
|
||||||
/// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by
|
/// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by
|
||||||
/// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's
|
/// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's
|
||||||
@@ -446,6 +478,24 @@ mod tests {
|
|||||||
assert_eq!(c.game_fps(0), 0);
|
assert_eq!(c.game_fps(0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_rate_list_parses_and_tolerates_junk() {
|
||||||
|
assert_eq!(parse_refresh_rates(Some("60,90,120")), vec![60, 90, 120]);
|
||||||
|
// Spaces, unsorted input and duplicates all normalise.
|
||||||
|
assert_eq!(
|
||||||
|
parse_refresh_rates(Some(" 120, 60 ,90, 60")),
|
||||||
|
vec![60, 90, 120]
|
||||||
|
);
|
||||||
|
// Unset and empty are the default: advertise only the session's own rate.
|
||||||
|
assert!(parse_refresh_rates(None).is_empty());
|
||||||
|
assert!(parse_refresh_rates(Some("")).is_empty());
|
||||||
|
assert!(parse_refresh_rates(Some(" ")).is_empty());
|
||||||
|
// A typo costs its own entry, never the whole list — the knob only widens a menu.
|
||||||
|
assert_eq!(parse_refresh_rates(Some("60,abc,120")), vec![60, 120]);
|
||||||
|
// Out of range in both directions (0 is not a refresh rate; 1920 is a width).
|
||||||
|
assert_eq!(parse_refresh_rates(Some("0,60,1920")), vec![60]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_output_mode_parses_its_spellings() {
|
fn audio_output_mode_parses_its_spellings() {
|
||||||
for (s, want) in [
|
for (s, want) in [
|
||||||
|
|||||||
@@ -130,6 +130,22 @@ impl Compositor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does this backend need a compositor that is ALREADY RUNNING for this uid?
|
||||||
|
///
|
||||||
|
/// Every desktop backend attaches to a live session — it asks Mutter/KWin/sway/Hyprland to mint
|
||||||
|
/// a virtual output over their IPC, so with nothing running there is no one to ask and `create`
|
||||||
|
/// can only fail (on GNOME: `RemoteDesktop.CreateSession:
|
||||||
|
/// org.freedesktop.DBus.Error.ServiceUnknown`). [`Compositor::Gamescope`] is the exception: it
|
||||||
|
/// stands its own session up from nothing (bare headless spawn / managed takeover), which is
|
||||||
|
/// exactly why a headless box pins to it.
|
||||||
|
///
|
||||||
|
/// Callers use this to tell "the session is up" from "the session is a corpse" BEFORE marching a
|
||||||
|
/// client into a doomed bring-up — the state a compositor crash leaves behind (gnome-shell
|
||||||
|
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so it never returns on its own).
|
||||||
|
pub fn needs_live_session(self) -> bool {
|
||||||
|
!matches!(self, Compositor::Gamescope)
|
||||||
|
}
|
||||||
|
|
||||||
/// Human label for UIs.
|
/// Human label for UIs.
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mod heads;
|
|||||||
mod splash;
|
mod splash;
|
||||||
use discovery::{
|
use discovery::{
|
||||||
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
|
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
|
||||||
|
gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates,
|
||||||
gamescope_node_present, poll_managed_node, wait_for_node,
|
gamescope_node_present, poll_managed_node, wait_for_node,
|
||||||
};
|
};
|
||||||
pub(crate) use discovery::{
|
pub(crate) use discovery::{
|
||||||
@@ -1153,17 +1154,9 @@ fn gamescope_argvs() -> Vec<Vec<String>> {
|
|||||||
/// also the final filter that separates a compositor from anything else [`gamescope_argvs`] let by.
|
/// also the final filter that separates a compositor from anything else [`gamescope_argvs`] let by.
|
||||||
fn current_gamescope_output_size() -> Option<(u32, u32)> {
|
fn current_gamescope_output_size() -> Option<(u32, u32)> {
|
||||||
gamescope_argvs().into_iter().find_map(|args| {
|
gamescope_argvs().into_iter().find_map(|args| {
|
||||||
let flag = |names: &[&str]| -> Option<u32> {
|
|
||||||
args.iter().enumerate().find_map(|(i, a)| {
|
|
||||||
names
|
|
||||||
.contains(&a.as_str())
|
|
||||||
.then(|| args.get(i + 1).and_then(|v| v.parse().ok()))
|
|
||||||
.flatten()
|
|
||||||
})
|
|
||||||
};
|
|
||||||
match (
|
match (
|
||||||
flag(&["-W", "--output-width"]),
|
argv_u32(&args, &["-W", "--output-width"]),
|
||||||
flag(&["-H", "--output-height"]),
|
argv_u32(&args, &["-H", "--output-height"]),
|
||||||
) {
|
) {
|
||||||
(Some(w), Some(h)) => Some((w, h)),
|
(Some(w), Some(h)) => Some((w, h)),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -1171,6 +1164,104 @@ fn current_gamescope_output_size() -> Option<(u32, u32)> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The numeric value following the first of `names` present in `argv`. Pure + unit-tested — it is
|
||||||
|
/// the shared reader behind both the output-size probe above and the mode verification below.
|
||||||
|
fn argv_u32(argv: &[String], names: &[&str]) -> Option<u32> {
|
||||||
|
argv.iter().enumerate().find_map(|(i, a)| {
|
||||||
|
names
|
||||||
|
.contains(&a.as_str())
|
||||||
|
.then(|| argv.get(i + 1).and_then(|v| v.parse().ok()))
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did the MODE we asked an indirectly-spawned session for actually reach its gamescope?
|
||||||
|
///
|
||||||
|
/// [`verify_managed_spawn_flags`] answers the same question for the capability flags and REFUSES
|
||||||
|
/// the session when they are missing, because the retry then resolves a different (correct) plan.
|
||||||
|
/// The mode has no such recovery: relaunching would hand the session the exact same environment and
|
||||||
|
/// lose it the same way, so refusing would only loop. It is not silent either, though — and it used
|
||||||
|
/// to be, in the way that costs the most:
|
||||||
|
///
|
||||||
|
/// `--nested-refresh` is the ONLY refresh a headless gamescope has. `CHeadlessBackend::Init`
|
||||||
|
/// assigns `g_nOutputRefresh = g_nNestedRefresh`, defaulting to **60 Hz** when the flag is absent,
|
||||||
|
/// and that one number is what the session composites at, what `vblankmanager` paces to, and what
|
||||||
|
/// Steam and every game are told the display runs at. It reaches a `gamescope-session-plus` only
|
||||||
|
/// through the `GAMESCOPE_BIN` wrapper — which the session script is free to lose (a `sessions.d`
|
||||||
|
/// file sourced with `set -a` can reassign `GAMESCOPE_BIN`; one that sets `GAMESCOPECMD` outright
|
||||||
|
/// skips the whole builder). When that happened the stream still ran, still looked right, and still
|
||||||
|
/// showed the client's own fps counter at the negotiated rate — because the encode loop repeats the
|
||||||
|
/// held frame — while the game underneath was capped to 60. Field report 2026-08-08.
|
||||||
|
///
|
||||||
|
/// So: warn, name the numbers, and carry on. Same "any running gamescope carrying it" rule as the
|
||||||
|
/// flag check, and the same silence when `/proc` cannot be read.
|
||||||
|
fn warn_if_mode_lost(mode: Mode, want_hz: u32) {
|
||||||
|
let argvs = gamescope_argvs();
|
||||||
|
let lost = mode_mismatch(mode.width, mode.height, want_hz, &argvs);
|
||||||
|
if lost.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
lost = %lost.join(", "),
|
||||||
|
"gamescope: the session did not start at the mode we asked for — the session script \
|
||||||
|
dropped GAMESCOPE_BIN / SCREEN_WIDTH / SCREEN_HEIGHT. A headless gamescope reports \
|
||||||
|
`--nested-refresh` as its ONE refresh rate (60 Hz when the flag never arrives), so games \
|
||||||
|
and Steam will believe the display runs at that rate however fast the stream is. Install \
|
||||||
|
punktfunk-gamescope, or check /etc/gamescope-session-plus/sessions.d/ for a file that \
|
||||||
|
overrides GAMESCOPE_BIN or sets GAMESCOPECMD"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which parts of the requested mode no running gamescope was started with, as human-readable
|
||||||
|
/// `asked=…, got=…` fragments. Empty when it matches — or when there is nothing to compare against,
|
||||||
|
/// which is the same fail-open rule [`missing_flags`] has and for the same reason. Pure +
|
||||||
|
/// unit-tested.
|
||||||
|
fn mode_mismatch(want_w: u32, want_h: u32, want_hz: u32, argvs: &[Vec<String>]) -> Vec<String> {
|
||||||
|
if argvs.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut lost = Vec::new();
|
||||||
|
let sizes: Vec<(u32, u32)> = argvs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| {
|
||||||
|
Some((
|
||||||
|
argv_u32(a, &["-W", "--output-width"])?,
|
||||||
|
argv_u32(a, &["-H", "--output-height"])?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// No gamescope carries an output size at all → we cannot tell ours apart from a nested one;
|
||||||
|
// stay quiet rather than warn on every box that runs a second gamescope.
|
||||||
|
if !sizes.is_empty() && !sizes.contains(&(want_w, want_h)) {
|
||||||
|
lost.push(format!(
|
||||||
|
"resolution asked={want_w}x{want_h}, got={}",
|
||||||
|
sizes
|
||||||
|
.iter()
|
||||||
|
.map(|(w, h)| format!("{w}x{h}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rates: Vec<u32> = argvs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| argv_u32(a, &["-r", "--nested-refresh"]))
|
||||||
|
.collect();
|
||||||
|
if !rates.contains(&want_hz) {
|
||||||
|
lost.push(match rates.as_slice() {
|
||||||
|
// The flag is absent everywhere — the exact shape that silently yields 60 Hz.
|
||||||
|
[] => format!(
|
||||||
|
"refresh asked={want_hz}Hz, got=no --nested-refresh at all (gamescope defaults to \
|
||||||
|
60Hz headless)"
|
||||||
|
),
|
||||||
|
got => format!(
|
||||||
|
"refresh asked={want_hz}Hz, got={}Hz",
|
||||||
|
got.iter().map(u32::to_string).collect::<Vec<_>>().join("/")
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lost
|
||||||
|
}
|
||||||
|
|
||||||
/// Did the flags we passed an INDIRECTLY-spawned session actually reach its gamescope?
|
/// Did the flags we passed an INDIRECTLY-spawned session actually reach its gamescope?
|
||||||
///
|
///
|
||||||
/// The bare spawn builds argv itself and cannot lose them. The two managed modes can: a
|
/// The bare spawn builds argv itself and cannot lose them. The two managed modes can: a
|
||||||
@@ -2337,12 +2428,29 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
|||||||
let wrapper = write_gamescope_bin_wrapper()?;
|
let wrapper = write_gamescope_bin_wrapper()?;
|
||||||
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean
|
||||||
let hz = mode.refresh_hz.max(1);
|
let hz = mode.refresh_hz.max(1);
|
||||||
// The two rates are deliberately different when the frame limiter is set. CUSTOM_REFRESH_RATES
|
// ONE rate reaches gamescope, and it is `--nested-refresh` (via the wrapper's `PF_HZ`). On the
|
||||||
// generates the mode the session ADVERTISES, which must stay the client's — that is what makes
|
// headless backend that flag IS the output refresh — `CHeadlessBackend::Init` assigns
|
||||||
// games see the real refresh instead of the box's EDID. PF_HZ becomes `--nested-refresh`, the
|
// `g_nOutputRefresh = g_nNestedRefresh` — so it is simultaneously the rate the session
|
||||||
// rate the game is clamped to, and is the only one the limiter touches. Identical when it's
|
// composites at, the rate `vblankmanager` paces to, and the rate Steam and every game are told
|
||||||
// unset, which is the default.
|
// the display runs at. When the frame limiter (`PUNKTFUNK_MAX_FPS`) is set they all drop
|
||||||
|
// together; that is the trade the knob is, and it is off by default.
|
||||||
|
//
|
||||||
|
// `CUSTOM_REFRESH_RATES` below does NOT do this, whatever its name suggests: it is the *set* of
|
||||||
|
// rates the session may offer, and `gamescope-session-plus` gates it on the binary having
|
||||||
|
// `--custom-refresh-rates`, which no upstream gamescope has ever had. On a stock gamescope it
|
||||||
|
// is inert (it was a silent no-op for years); on our `+pfhdr3` build it is what puts more than
|
||||||
|
// one entry in Steam's refresh menu. Either way it cannot fix a wrong `--nested-refresh`.
|
||||||
let game = game_hz(mode.refresh_hz);
|
let game = game_hz(mode.refresh_hz);
|
||||||
|
// The advertised SET, which always contains the rate we actually run at.
|
||||||
|
let offered = {
|
||||||
|
let mut r = pf_host_config::config().gamescope_refresh_rates.clone();
|
||||||
|
if !r.contains(&hz) {
|
||||||
|
r.push(hz);
|
||||||
|
}
|
||||||
|
r.sort_unstable();
|
||||||
|
r.dedup();
|
||||||
|
r.iter().map(u32::to_string).collect::<Vec<_>>().join(",")
|
||||||
|
};
|
||||||
let start_unit = || -> Result<()> {
|
let start_unit = || -> Result<()> {
|
||||||
let status = Command::new("systemd-run")
|
let status = Command::new("systemd-run")
|
||||||
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
.args(["--user", "--collect", &format!("--unit={unit_name}")])
|
||||||
@@ -2366,7 +2474,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
|||||||
))
|
))
|
||||||
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
.arg(format!("--setenv=GAMESCOPE_BIN={}", wrapper.display()))
|
||||||
.arg("--setenv=DRM_MODE=cvt")
|
.arg("--setenv=DRM_MODE=cvt")
|
||||||
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={hz}"))
|
.arg(format!("--setenv=CUSTOM_REFRESH_RATES={offered}"))
|
||||||
.arg("--")
|
.arg("--")
|
||||||
.arg(SESSION_PLUS_BIN)
|
.arg(SESSION_PLUS_BIN)
|
||||||
.arg(client)
|
.arg(client)
|
||||||
@@ -2394,6 +2502,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
|||||||
stop_session(unit_name);
|
stop_session(unit_name);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
// Loud, but not fatal — see [`warn_if_mode_lost`] for why this one warns where the
|
||||||
|
// capability flags above refuse.
|
||||||
|
warn_if_mode_lost(mode, game);
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
@@ -2526,7 +2637,15 @@ fn add_bare_gamescope_args(
|
|||||||
if grab_cursor {
|
if grab_cursor {
|
||||||
command.arg("--force-grab-cursor");
|
command.arg("--force-grab-cursor");
|
||||||
}
|
}
|
||||||
for arg in hdr_args(hdr).into_iter().chain(cursor_args()) {
|
// `-r` above is what this headless session will REPORT as its refresh (the headless backend
|
||||||
|
// assigns `g_nOutputRefresh = g_nNestedRefresh`), so it is already correct here. This adds the
|
||||||
|
// rest of the SET the in-session UI may offer — the bare spawn passes it directly, with none of
|
||||||
|
// the session-script indirection the managed path has to route it through.
|
||||||
|
for arg in hdr_args(hdr)
|
||||||
|
.into_iter()
|
||||||
|
.chain(cursor_args())
|
||||||
|
.chain(refresh_rate_args(hz))
|
||||||
|
{
|
||||||
command.arg(arg);
|
command.arg(arg);
|
||||||
}
|
}
|
||||||
command.args(["--xwayland-count", "1", "--"]);
|
command.args(["--xwayland-count", "1", "--"]);
|
||||||
@@ -2571,11 +2690,48 @@ fn hdr_args(hdr: bool) -> Vec<String> {
|
|||||||
/// host-side (it costs the host a full-frame pass, and on the zero-CSC encode source it cannot be
|
/// host-side (it costs the host a full-frame pass, and on the zero-CSC encode source it cannot be
|
||||||
/// done at all). Empty on a stock gamescope, which is exactly the old behaviour.
|
/// done at all). Empty on a stock gamescope, which is exactly the old behaviour.
|
||||||
fn cursor_args() -> Vec<String> {
|
fn cursor_args() -> Vec<String> {
|
||||||
|
let mut args = Vec::new();
|
||||||
if gamescope_can_composite_cursor() {
|
if gamescope_can_composite_cursor() {
|
||||||
vec!["--pipewire-composite-cursor".to_string()]
|
args.push("--pipewire-composite-cursor".to_string());
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
}
|
||||||
|
// The external overlay (mangoapp — the Deck UI's fps/frametime readout, patch level 4+). Unlike
|
||||||
|
// the cursor there is no host-side fallback: the host cannot reconstruct another process's
|
||||||
|
// overlay window, so without this the layer is simply absent from every gamescope stream.
|
||||||
|
if gamescope_can_composite_external_overlay() {
|
||||||
|
args.push("--pipewire-composite-external-overlay".to_string());
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--custom-refresh-rates <list>` when the resolved gamescope has it (patch level 3+): the rates a
|
||||||
|
/// HEADLESS session may offer its clients.
|
||||||
|
///
|
||||||
|
/// Without it a headless connector advertises exactly one rate, so Steam's in-session display
|
||||||
|
/// settings show a single entry and a game reads the display as that one number. `session_hz` is
|
||||||
|
/// always in the list — it is the mode the session actually runs at, and an advertised set that
|
||||||
|
/// excluded it would be a lie in the other direction.
|
||||||
|
///
|
||||||
|
/// The operator can widen the set (`PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120`) so the in-session
|
||||||
|
/// UI offers real choices; unset, we advertise the one rate we run at, which is what the client
|
||||||
|
/// asked for.
|
||||||
|
fn refresh_rate_args(session_hz: u32) -> Vec<String> {
|
||||||
|
if !gamescope_can_offer_refresh_rates() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut rates = pf_host_config::config().gamescope_refresh_rates.clone();
|
||||||
|
if !rates.contains(&session_hz) {
|
||||||
|
rates.push(session_hz);
|
||||||
|
}
|
||||||
|
rates.sort_unstable();
|
||||||
|
rates.dedup();
|
||||||
|
vec![
|
||||||
|
"--custom-refresh-rates".to_string(),
|
||||||
|
rates
|
||||||
|
.iter()
|
||||||
|
.map(u32::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||||
@@ -2717,7 +2873,7 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
|
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
|
||||||
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
|
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
|
||||||
is_steam_launch, missing_flags, nested_wrapper_script, sentinel_advanced,
|
is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, sentinel_advanced,
|
||||||
shape_dedicated_command,
|
shape_dedicated_command,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2949,6 +3105,63 @@ mod tests {
|
|||||||
assert!(!cgroup_is_punktfunk_owned(""));
|
assert!(!cgroup_is_punktfunk_owned(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The silent-60Hz guard. A headless gamescope reports `--nested-refresh` as its ONE refresh
|
||||||
|
/// rate and falls back to 60 Hz when the flag never arrives, so a session that lost the
|
||||||
|
/// `GAMESCOPE_BIN` wrapper streams at the client's rate while telling every game it is 60 —
|
||||||
|
/// the exact shape of the 2026-08-08 field report, and invisible without this.
|
||||||
|
#[test]
|
||||||
|
fn mode_mismatch_names_what_the_session_actually_got() {
|
||||||
|
let argv = |s: &str| -> Vec<String> { s.split(' ').map(str::to_string).collect() };
|
||||||
|
|
||||||
|
// The good case: our own managed spawn, carrying everything we asked for.
|
||||||
|
let ok = vec![argv(
|
||||||
|
"/usr/bin/gamescope --backend headless -W 1920 -H 1080 --nested-refresh 120 --steam",
|
||||||
|
)];
|
||||||
|
assert!(mode_mismatch(1920, 1080, 120, &ok).is_empty());
|
||||||
|
|
||||||
|
// THE field case: the wrapper was dropped, so there is no `--nested-refresh` anywhere and
|
||||||
|
// gamescope silently ran its 60 Hz default. Size still landed (SCREEN_WIDTH survived).
|
||||||
|
let lost = vec![argv(
|
||||||
|
"/usr/bin/gamescope --backend headless -W 1920 -H 1080 --steam",
|
||||||
|
)];
|
||||||
|
let got = mode_mismatch(1920, 1080, 120, &lost);
|
||||||
|
assert_eq!(got.len(), 1, "only the refresh is wrong: {got:?}");
|
||||||
|
assert!(got[0].contains("asked=120Hz"), "{got:?}");
|
||||||
|
assert!(got[0].contains("no --nested-refresh at all"), "{got:?}");
|
||||||
|
|
||||||
|
// A wrong rate is reported with the number it actually got, not just "missing".
|
||||||
|
let wrong = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh 60")];
|
||||||
|
let got = mode_mismatch(1920, 1080, 120, &wrong);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert!(got[0].contains("got=60Hz"), "{got:?}");
|
||||||
|
|
||||||
|
// Resolution lost too (SCREEN_WIDTH/HEIGHT dropped as well) — both are named.
|
||||||
|
let both = vec![argv("gamescope -W 1280 -H 720")];
|
||||||
|
assert_eq!(mode_mismatch(1920, 1080, 120, &both).len(), 2);
|
||||||
|
|
||||||
|
// Fail OPEN, exactly like `missing_flags`: nothing to compare against says nothing. A box
|
||||||
|
// with a second gamescope that carries no output size must not produce a false alarm.
|
||||||
|
assert!(mode_mismatch(1920, 1080, 120, &[]).is_empty());
|
||||||
|
|
||||||
|
// ANY running gamescope carrying the mode satisfies it — a Deck commonly runs a nested one
|
||||||
|
// beside the session, and demanding that every gamescope match would reject a good session.
|
||||||
|
let two = vec![
|
||||||
|
argv("gamescope -W 1280 -H 800 --nested-refresh 60"),
|
||||||
|
argv("gamescope -W 1920 -H 1080 --nested-refresh 120"),
|
||||||
|
];
|
||||||
|
assert!(mode_mismatch(1920, 1080, 120, &two).is_empty());
|
||||||
|
|
||||||
|
// The long spellings are read too.
|
||||||
|
let long = vec![argv(
|
||||||
|
"gamescope --output-width 1920 --output-height 1080 --nested-refresh 120",
|
||||||
|
)];
|
||||||
|
assert!(mode_mismatch(1920, 1080, 120, &long).is_empty());
|
||||||
|
|
||||||
|
// A flag with no value after it must not panic or read past the end.
|
||||||
|
let truncated = vec![argv("gamescope -W 1920 -H 1080 --nested-refresh")];
|
||||||
|
assert_eq!(mode_mismatch(1920, 1080, 120, &truncated).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// The silent-cursor guard: a managed session that ignored `GAMESCOPE_BIN` / the PATH shim runs
|
/// The silent-cursor guard: a managed session that ignored `GAMESCOPE_BIN` / the PATH shim runs
|
||||||
/// a stock gamescope, and the host — already told the compositor would paint the pointer —
|
/// a stock gamescope, and the host — already told the compositor would paint the pointer —
|
||||||
/// paints none either. Only a compositor we can SEE, missing a flag we can NAME, may fail.
|
/// paints none either. Only a compositor we can SEE, missing a flag we can NAME, may fail.
|
||||||
|
|||||||
@@ -449,6 +449,34 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool {
|
|||||||
gamescope_patch_level() >= 2 && !flags_lost()
|
gamescope_patch_level() >= 2 && !flags_lost()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does the resolved gamescope let us hand a headless session the list of refresh rates it may
|
||||||
|
/// offer (`--custom-refresh-rates`)?
|
||||||
|
///
|
||||||
|
/// Below this level a headless gamescope advertises **one** rate — whatever `--nested-refresh`
|
||||||
|
/// resolved to, or its own 60 Hz default — and no resolution list at all, because its connector
|
||||||
|
/// returns empty spans from `GetModes()`/`GetValidDynamicRefreshRates()` and reports an INTERNAL
|
||||||
|
/// screen (which makes `update_mode_atoms` delete the mode-list atom outright). So on a stock
|
||||||
|
/// gamescope, Steam's in-session display settings show exactly one refresh rate and no
|
||||||
|
/// resolutions, and games read the display as 60 Hz whatever the client negotiated.
|
||||||
|
///
|
||||||
|
/// `gamescope-session-plus` has probed for this flag for years (`CUSTOM_REFRESH_RATES` is gated on
|
||||||
|
/// `gamescope --help` mentioning it) — upstream simply never had it, so the env var it plumbs was
|
||||||
|
/// a no-op everywhere.
|
||||||
|
pub(crate) fn gamescope_can_offer_refresh_rates() -> bool {
|
||||||
|
gamescope_patch_level() >= 3 && !flags_lost()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can the resolved gamescope paint the EXTERNAL OVERLAY — mangoapp, the Deck-UI fps/frametime
|
||||||
|
/// readout — into its PipeWire node (`--pipewire-composite-external-overlay`)?
|
||||||
|
///
|
||||||
|
/// `paint_pipewire` has never referenced that layer on any upstream version, so a client whose
|
||||||
|
/// only view of the session is the node sees the overlay it just enabled simply not appear.
|
||||||
|
/// Unlike the cursor there is no host-side substitute: the host cannot reconstruct someone else's
|
||||||
|
/// overlay window.
|
||||||
|
pub(crate) fn gamescope_can_composite_external_overlay() -> bool {
|
||||||
|
gamescope_patch_level() >= 4 && !flags_lost()
|
||||||
|
}
|
||||||
|
|
||||||
/// Has a spawn been observed where our flags did NOT reach the gamescope process?
|
/// Has a spawn been observed where our flags did NOT reach the gamescope process?
|
||||||
///
|
///
|
||||||
/// The binary probe above answers "can it", which is all the bare spawn needs — there we build
|
/// The binary probe above answers "can it", which is all the bare spawn needs — there we build
|
||||||
|
|||||||
@@ -299,16 +299,21 @@ struct Pinger {
|
|||||||
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
|
||||||
/// cached handle (every IOCTL fails with a gone-class code forever), so such a failure RETIRES it and
|
/// cached handle (every IOCTL fails with a gone-class code forever), so such a failure RETIRES it and
|
||||||
/// the next [`VirtualDisplayManager::ensure_device`] reopens the (new) device interface, re-running
|
/// the next [`VirtualDisplayManager::ensure_device`] reopens the (new) device interface, re-running
|
||||||
/// the version handshake. Retired handles are deliberately kept alive — never closed — for the
|
/// the version handshake.
|
||||||
/// process lifetime: the pinger/linger threads and every capturer's `ChannelBroker` hold BARE
|
///
|
||||||
/// `HANDLE` copies whose soundness contract is "never closed"; a retired handle only ever FAILS
|
/// Ownership is `Arc` all the way out: every consumer — `acquire`'s IOCTL runs, the pinger/linger
|
||||||
/// IOCTLs, which every holder already tolerates. Reopens are rare (a driver restart), so the retained
|
/// threads, the capture layer's delivery closures — holds its OWN clone across its use, so retiring
|
||||||
/// list is bounded in practice.
|
/// here merely drops the manager's reference and the handle CLOSES when the last in-flight user
|
||||||
|
/// drains. That close is load-bearing, not housekeeping: an open control handle is exactly what
|
||||||
|
/// vetoes the PnP disable/restart the wake-from-sleep recovery leans on (field 2026-08-08 — every
|
||||||
|
/// reload REFUSED `Generic failure`; `reset-pf-vdisplay.ps1` stops the whole host service precisely
|
||||||
|
/// to get its handles closed, and Arc ownership buys the same release without dying). The previous
|
||||||
|
/// contract kept retired handles open for the process lifetime because bare `HANDLE` copies were
|
||||||
|
/// smuggled into threads and closures; those copies are gone, and nothing may rely on a dead
|
||||||
|
/// handle staying open again.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct DeviceSlot {
|
struct DeviceSlot {
|
||||||
current: Option<Arc<OwnedHandle>>,
|
current: Option<Arc<OwnedHandle>>,
|
||||||
/// Never dropped — see the type doc (bare-`HANDLE` holders rely on no-close).
|
|
||||||
retired: Vec<Arc<OwnedHandle>>,
|
|
||||||
/// `CLEAR_ALL` (crashed-host orphan reap) runs only on the FIRST open of the process; a reopen
|
/// `CLEAR_ALL` (crashed-host orphan reap) runs only on the FIRST open of the process; a reopen
|
||||||
/// races sessions this process still considers live and must not raze them.
|
/// races sessions this process still considers live and must not raze them.
|
||||||
opened_once: bool,
|
opened_once: bool,
|
||||||
@@ -397,11 +402,6 @@ pub fn vdm() -> &'static VirtualDisplayManager {
|
|||||||
.expect("VirtualDisplayManager used before a backend initialised it")
|
.expect("VirtualDisplayManager used before a backend initialised it")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
|
|
||||||
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: cached handles are never closed
|
|
||||||
/// for the process lifetime — a dead one is RETIRED (kept alive, see [`DeviceSlot`]), so a stale copy
|
|
||||||
/// can only fail IOCTLs, never dangle. `None` before the first backend open — impossible for a
|
|
||||||
/// capturer, which only exists on a monitor the manager created.
|
|
||||||
/// Can this host's pf-vdisplay driver run the v5 hardware-cursor channel? Reads the
|
/// Can this host's pf-vdisplay driver run the v5 hardware-cursor channel? Reads the
|
||||||
/// handshake-latched protocol version, opening the control device once if no session has
|
/// handshake-latched protocol version, opening the control device once if no session has
|
||||||
/// opened it yet this service run (the same open every session performs anyway) — so the
|
/// opened it yet this service run (the same open every session performs anyway) — so the
|
||||||
@@ -421,7 +421,13 @@ pub fn hw_cursor_capable() -> bool {
|
|||||||
m.driver_proto.load(Ordering::Relaxed) >= 5
|
m.driver_proto.load(Ordering::Relaxed) >= 5
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn control_device_handle() -> Option<HANDLE> {
|
/// The live pf-vdisplay control device, for the IDD-push capturer's sealed-channel delivery
|
||||||
|
/// (`IOCTL_SET_FRAME_CHANNEL`) — an `Arc` clone the caller (and every closure it builds) holds for
|
||||||
|
/// as long as it may issue IOCTLs: the handle stays open while any holder lives and closes when the
|
||||||
|
/// last drains, which is what lets the wake-from-sleep recovery's PnP disable proceed once the
|
||||||
|
/// manager retires it (see [`DeviceSlot`]). `None` before the first backend open — impossible for a
|
||||||
|
/// capturer, which only exists on a monitor the manager created.
|
||||||
|
pub fn control_device_handle() -> Option<Arc<OwnedHandle>> {
|
||||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,17 +503,28 @@ fn is_device_gone(e: &anyhow::Error) -> bool {
|
|||||||
GONE.contains(&w.code().0)
|
GONE.contains(&w.code().0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The transient raw `HANDLE` view of an Arc-held control device, for the backend IOCTL surface.
|
||||||
|
/// Sound only while the `Arc` it borrows from is held — which the borrow makes structural: every
|
||||||
|
/// use site necessarily has the owning clone alive across the call, so a concurrent retire (which
|
||||||
|
/// now really closes the handle once its users drain — see [`DeviceSlot`]) can never close it
|
||||||
|
/// mid-IOCTL.
|
||||||
|
fn dev_raw(dev: &OwnedHandle) -> HANDLE {
|
||||||
|
HANDLE(dev.as_raw_handle())
|
||||||
|
}
|
||||||
|
|
||||||
impl VirtualDisplayManager {
|
impl VirtualDisplayManager {
|
||||||
pub(crate) fn backend_name(&self) -> &'static str {
|
pub(crate) fn backend_name(&self) -> &'static str {
|
||||||
self.driver.name()
|
self.driver.name()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open + cache the control device; REOPEN when a gone-classified failure retired the cached one
|
/// Open + cache the control device; REOPEN when a gone-classified failure retired the cached one
|
||||||
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens.
|
/// (driver upgrade / WUDFHost restart). The `device` mutex serializes racing opens. Returns an
|
||||||
fn ensure_device(&self) -> Result<HANDLE> {
|
/// `Arc` clone the caller holds across every IOCTL it derives from it — a concurrent retire then
|
||||||
|
/// drops only the manager's reference and closes nothing under the caller (see [`DeviceSlot`]).
|
||||||
|
fn ensure_device(&self) -> Result<Arc<OwnedHandle>> {
|
||||||
let mut slot = self.device.lock().unwrap();
|
let mut slot = self.device.lock().unwrap();
|
||||||
if let Some(d) = &slot.current {
|
if let Some(d) = &slot.current {
|
||||||
return Ok(HANDLE(d.as_raw_handle()));
|
return Ok(d.clone());
|
||||||
}
|
}
|
||||||
let reap = !slot.opened_once;
|
let reap = !slot.opened_once;
|
||||||
claim_instance()?;
|
claim_instance()?;
|
||||||
@@ -519,35 +536,33 @@ impl VirtualDisplayManager {
|
|||||||
slot.opened_once = true;
|
slot.opened_once = true;
|
||||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||||
self.driver_proto.store(driver_proto, Ordering::Relaxed);
|
self.driver_proto.store(driver_proto, Ordering::Relaxed);
|
||||||
let raw = HANDLE(handle.as_raw_handle());
|
let dev = Arc::new(handle);
|
||||||
slot.current = Some(Arc::new(handle));
|
slot.current = Some(dev.clone());
|
||||||
if !reap {
|
if !reap {
|
||||||
tracing::info!("virtual-display control device reopened (retired handle replaced)");
|
tracing::info!("virtual-display control device reopened (retired handle replaced)");
|
||||||
}
|
}
|
||||||
Ok(raw)
|
Ok(dev)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The live control handle for the pinger/linger threads. `None` before the first acquire opened
|
/// The live control device for the pinger/linger threads — an `Arc` clone the caller holds
|
||||||
/// it, or between a retire and the next reopen.
|
/// across its IOCTLs. `None` before the first acquire opened it, or between a retire and the
|
||||||
fn device_handle(&self) -> Option<HANDLE> {
|
/// next reopen.
|
||||||
self.device
|
fn device_handle(&self) -> Option<Arc<OwnedHandle>> {
|
||||||
.lock()
|
self.device.lock().unwrap().current.clone()
|
||||||
.unwrap()
|
|
||||||
.current
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| HANDLE(d.as_raw_handle()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retire the cached control handle after a gone-classified IOCTL failure. The handle is retained
|
/// Retire the cached control handle after a gone-classified IOCTL failure: drop the manager's
|
||||||
/// un-closed (see [`DeviceSlot`]); the next [`ensure_device`](Self::ensure_device) reopens the
|
/// reference, so the handle CLOSES once the last in-flight user drains (see [`DeviceSlot`]) —
|
||||||
/// (new) device interface and re-runs the version handshake.
|
/// the release the wake-from-sleep recovery needs before it can cycle the adapter devnode. The
|
||||||
|
/// next [`ensure_device`](Self::ensure_device) reopens the (new) device interface and re-runs
|
||||||
|
/// the version handshake.
|
||||||
fn invalidate_device(&self, why: &anyhow::Error) {
|
fn invalidate_device(&self, why: &anyhow::Error) {
|
||||||
let mut slot = self.device.lock().unwrap();
|
let mut slot = self.device.lock().unwrap();
|
||||||
if let Some(cur) = slot.current.take() {
|
if slot.current.take().is_some() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"virtual-display control device retired — reopening on next use (cause: {why:#})"
|
"virtual-display control device retired — closes when its last user drains, \
|
||||||
|
reopening on next use (cause: {why:#})"
|
||||||
);
|
);
|
||||||
slot.retired.push(cur);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,11 +635,11 @@ impl VirtualDisplayManager {
|
|||||||
old_target,
|
old_target,
|
||||||
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
|
||||||
);
|
);
|
||||||
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the
|
// SAFETY: `teardown_removed` requires `dev` to be a valid control handle; the `dev`
|
||||||
// value `ensure_device()` returned above (cached handles are never closed — a dead one
|
// Arc `ensure_device()` returned above is held across this call, so the handle stays
|
||||||
// is retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||||
// is exclusively owned here — no aliasing.
|
// is exclusively owned here — no aliasing.
|
||||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||||
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
||||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||||
// Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3).
|
// Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3).
|
||||||
@@ -657,11 +672,11 @@ impl VirtualDisplayManager {
|
|||||||
wudf_pid = mon.wudf_pid,
|
wudf_pid = mon.wudf_pid,
|
||||||
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
|
"virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating"
|
||||||
);
|
);
|
||||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is the value
|
// SAFETY: `teardown_removed` requires a valid control handle; the `dev` Arc
|
||||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is
|
// `ensure_device()` returned above is held across this call, so the handle stays
|
||||||
// retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it
|
// open even against a concurrent retire. `mon` was just removed from the map, so it
|
||||||
// is exclusively owned here — no aliasing.
|
// is exclusively owned here — no aliasing.
|
||||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||||
// Same async-departure settle as the reconnect preempt above (verified wait, P0.3).
|
// Same async-departure settle as the reconnect preempt above (verified wait, P0.3).
|
||||||
let _ = wait_target_departed(old_target, Duration::from_millis(400));
|
let _ = wait_target_departed(old_target, Duration::from_millis(400));
|
||||||
}
|
}
|
||||||
@@ -693,9 +708,10 @@ impl VirtualDisplayManager {
|
|||||||
else {
|
else {
|
||||||
unreachable!("just matched Active");
|
unreachable!("just matched Active");
|
||||||
};
|
};
|
||||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; the CCD
|
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across
|
||||||
// waits inside run under the held `state` lock (this fn's discipline).
|
// this call (so the handle stays open); the CCD waits inside run under
|
||||||
match unsafe { self.resize_in_place(dev, mon, mode) } {
|
// the held `state` lock (this fn's discipline).
|
||||||
|
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Same join semantics as the re-arrival: +1 ref for the new
|
// Same join semantics as the re-arrival: +1 ref for the new
|
||||||
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
||||||
@@ -734,10 +750,11 @@ impl VirtualDisplayManager {
|
|||||||
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
||||||
unreachable!("just matched Active");
|
unreachable!("just matched Active");
|
||||||
};
|
};
|
||||||
// SAFETY: `dev` is the handle `ensure_device()` returned above; `re_add` touches the
|
// SAFETY: the `dev` Arc `ensure_device()` returned above is held across this call
|
||||||
// live topology under the held `state` lock. `mon` is owned here (removed from the map).
|
// (so the handle stays open); `re_add` touches the live topology under the held
|
||||||
|
// `state` lock. `mon` is owned here (removed from the map).
|
||||||
let new_mon = match unsafe {
|
let new_mon = match unsafe {
|
||||||
self.re_add(dev, &mut inner, slot, &mon, mode, client_hdr)
|
self.re_add(dev_raw(&dev), &mut inner, slot, &mon, mode, client_hdr)
|
||||||
} {
|
} {
|
||||||
ReAdd::Arrived(m) => *m,
|
ReAdd::Arrived(m) => *m,
|
||||||
ReAdd::RolledBack {
|
ReAdd::RolledBack {
|
||||||
@@ -815,11 +832,11 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The slot is empty: create a fresh monitor for it.
|
// The slot is empty: create a fresh monitor for it.
|
||||||
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the handle
|
// SAFETY: `create_monitor` requires `dev` to be a valid control handle; the `dev` Arc
|
||||||
// `ensure_device()` returned above (cached handles are never closed — a dead one is retired,
|
// `ensure_device()` returned above is held across this call (so the handle stays open even
|
||||||
// kept alive; see `DeviceSlot`), and we hold the `state` lock.
|
// against a concurrent retire), and we hold the `state` lock.
|
||||||
let mon = match unsafe {
|
let mon = match unsafe {
|
||||||
self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)
|
self.create_monitor(dev_raw(&dev), mode, slot, client_hdr, hw_cursor, &mut inner)
|
||||||
} {
|
} {
|
||||||
// The cached device died under us (driver upgrade / WUDFHost restart, detected only
|
// The cached device died under us (driver upgrade / WUDFHost restart, detected only
|
||||||
// now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and
|
// now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and
|
||||||
@@ -831,9 +848,18 @@ impl VirtualDisplayManager {
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
"virtual-display control device reopened — retrying the monitor create"
|
"virtual-display control device reopened — retrying the monitor create"
|
||||||
);
|
);
|
||||||
// SAFETY: as above — `dev` is the handle the reopening `ensure_device` just
|
// SAFETY: as above — the `dev` Arc the reopening `ensure_device` just returned is
|
||||||
// returned, and the `state` lock is still held.
|
// held across this call, and the `state` lock is still held.
|
||||||
unsafe { self.create_monitor(dev, mode, slot, client_hdr, hw_cursor, &mut inner)? }
|
unsafe {
|
||||||
|
self.create_monitor(
|
||||||
|
dev_raw(&dev),
|
||||||
|
mode,
|
||||||
|
slot,
|
||||||
|
client_hdr,
|
||||||
|
hw_cursor,
|
||||||
|
&mut inner,
|
||||||
|
)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
r => r?,
|
r => r?,
|
||||||
};
|
};
|
||||||
@@ -887,13 +913,12 @@ impl VirtualDisplayManager {
|
|||||||
let mut warned = false;
|
let mut warned = false;
|
||||||
while !stop_t.load(Ordering::Relaxed) {
|
while !stop_t.load(Ordering::Relaxed) {
|
||||||
if let Some(h) = vdm().device_handle() {
|
if let Some(h) = vdm().device_handle() {
|
||||||
// SAFETY: `ping` requires `dev` to be a valid control handle. `h` is from
|
// SAFETY: `ping` requires `dev` to be a valid control handle. The `h` Arc from
|
||||||
// `device_handle()` (the `Some` branch) — cached handles are NEVER closed for the
|
// `device_handle()` is held across this call, so the handle stays open even if
|
||||||
// process lifetime (a dead one is retired, kept alive; see `DeviceSlot`), so the
|
// it is retired concurrently — at worst the IOCTL fails (the retire drops only
|
||||||
// handle stays valid for this call even if it was retired concurrently — at worst
|
// the manager's reference; see `DeviceSlot`). The pinger thread only spins
|
||||||
// the IOCTL fails. The pinger thread only spins while the `&'static` manager
|
// while the `&'static` manager singleton lives.
|
||||||
// singleton lives.
|
match unsafe { vdm().driver.ping(dev_raw(&h)) } {
|
||||||
match unsafe { vdm().driver.ping(h) } {
|
|
||||||
Ok(()) => warned = false,
|
Ok(()) => warned = false,
|
||||||
Err(e) if is_device_gone(&e) => {
|
Err(e) if is_device_gone(&e) => {
|
||||||
// The device itself is gone (driver upgrade / WUDFHost restart) — pings
|
// The device itself is gone (driver upgrade / WUDFHost restart) — pings
|
||||||
@@ -1897,12 +1922,11 @@ impl VirtualDisplayManager {
|
|||||||
slot,
|
slot,
|
||||||
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
|
"virtual-display: last session left (deliberate quit) — tearing down now, linger skipped"
|
||||||
);
|
);
|
||||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; `dev`
|
// SAFETY: `teardown_removed` requires `dev` to be the live control handle; the
|
||||||
// is the cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some`
|
// `dev` Arc from `device_handle()` (the `Some` checked above) is held across
|
||||||
// checked above; cached handles are never closed — a dead one is retired, kept
|
// this call, so the handle stays open. `mon` was moved out of the map under the
|
||||||
// alive). `mon` was moved out of the map under the `state` lock, so it is
|
// `state` lock, so it is exclusively owned here — no aliasing.
|
||||||
// exclusively owned here — no aliasing.
|
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
inner.slots.insert(
|
inner.slots.insert(
|
||||||
@@ -1980,10 +2004,10 @@ impl VirtualDisplayManager {
|
|||||||
"IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)"
|
"IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)"
|
||||||
);
|
);
|
||||||
// SAFETY: `teardown_removed` requires `dev` to be the live control handle;
|
// SAFETY: `teardown_removed` requires `dev` to be the live control handle;
|
||||||
// `dev` is the cached process-lifetime `OwnedHandle` from `device_handle()`
|
// the `dev` Arc from `device_handle()` (the `Some` checked above) is held
|
||||||
// (the `Some` checked above). `mon` was moved out of the map under the
|
// across this call, so the handle stays open. `mon` was moved out of the
|
||||||
// `state` lock, so it is exclusively owned here — no aliasing.
|
// map under the `state` lock, so it is exclusively owned here — no aliasing.
|
||||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||||
// Let the OS finish the ASYNC departure before the next ADD (mirrors the
|
// Let the OS finish the ASYNC departure before the next ADD (mirrors the
|
||||||
// acquire() Lingering-preempt settle).
|
// acquire() Lingering-preempt settle).
|
||||||
thread::sleep(Duration::from_millis(400));
|
thread::sleep(Duration::from_millis(400));
|
||||||
@@ -2051,11 +2075,12 @@ impl VirtualDisplayManager {
|
|||||||
// its session. Lock order stays state → device (teardown's invalidate
|
// its session. Lock order stays state → device (teardown's invalidate
|
||||||
// path), same as every other holder; the pinger takes only the device
|
// path), same as every other holder; the pinger takes only the device
|
||||||
// lock — no inversion.
|
// lock — no inversion.
|
||||||
// SAFETY: `teardown_removed` requires a valid control handle; `dev` is
|
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
|
||||||
// from `self.device_handle()` (cached handles are never closed — a dead
|
// Arc from `self.device_handle()` is held across this call, so the
|
||||||
// one is retired, kept alive; see `DeviceSlot`). `mon` was moved out of
|
// handle stays open (a concurrent retire drops only the manager's
|
||||||
// the map under the lock, so it is exclusively owned here.
|
// reference; see `DeviceSlot`). `mon` was moved out of the map under
|
||||||
unsafe { self.teardown_removed(dev, &mut g, mon) };
|
// the lock, so it is exclusively owned here.
|
||||||
|
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2218,11 +2243,11 @@ impl VirtualDisplayManager {
|
|||||||
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
|
if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) =
|
||||||
inner.slots.remove(&k)
|
inner.slots.remove(&k)
|
||||||
{
|
{
|
||||||
// SAFETY: `teardown_removed` needs a live control handle; `dev` is from
|
// SAFETY: `teardown_removed` needs a live control handle; the `dev` Arc from
|
||||||
// `device_handle()` (cached handles are never closed — a dead one is retired, kept
|
// `device_handle()` is held across this call, so the handle stays open (see
|
||||||
// alive; see `DeviceSlot`). `mon` was moved out of the map under the `state` lock,
|
// `DeviceSlot`). `mon` was moved out of the map under the `state` lock, so it is
|
||||||
// so it is exclusively owned here — no aliasing.
|
// exclusively owned here — no aliasing.
|
||||||
unsafe { self.teardown_removed(dev, &mut inner, mon) };
|
unsafe { self.teardown_removed(dev_raw(&dev), &mut inner, mon) };
|
||||||
released += 1;
|
released += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,14 +100,27 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
|
|||||||
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
|
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
|
||||||
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
|
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
|
||||||
/// failure is logged and swallowed. Returns the number removed.
|
/// failure is logged and swallowed. Returns the number removed.
|
||||||
|
///
|
||||||
|
/// The outcome is logged UNCONDITIONALLY, as found + removed: the old script counted only removals
|
||||||
|
/// and the host spoke only when that count was positive, so a reap whose pnputil never launched and
|
||||||
|
/// a box with no ghosts produced byte-identical logs (silence) — the same vacuous-signal family as
|
||||||
|
/// the `status=OK` trap [`reload_vdisplay_adapter`] answers — while ghosts ratcheted toward the
|
||||||
|
/// wedge with every sleep cycle.
|
||||||
fn reap_ghost_monitors() -> u32 {
|
fn reap_ghost_monitors() -> u32 {
|
||||||
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
|
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
|
||||||
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
|
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
|
||||||
// non-English box (unlike a .ps1 *file* read in the machine codepage).
|
// non-English box (unlike a .ps1 *file* read in the machine codepage).
|
||||||
|
//
|
||||||
|
// pnputil is resolved by full path and `$LASTEXITCODE` pre-seeded to failure before every
|
||||||
|
// launch, exactly like the reload path below: a LocalSystem service's PATH need not include
|
||||||
|
// System32 (and a SYSTEM process must not trust PATH anyway — a planted `pnputil.exe` would run
|
||||||
|
// elevated), and the old bare-name call failed INVISIBLY there — `SilentlyContinue` swallowed
|
||||||
|
// the miss, no exit code was written, and the ghosts stayed to wedge `IOCTL_ADD` at 0x80070490.
|
||||||
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||||
$g = Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }; \
|
$g = @(Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }); \
|
||||||
$n = 0; foreach ($d in $g) { pnputil /remove-device $d.InstanceId *> $null; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); \
|
||||||
Write-Output $n";
|
$n = 0; foreach ($d in $g) { $LASTEXITCODE = 1; if (Test-Path $pnp) { & $pnp /remove-device $d.InstanceId *> $null }; if ($LASTEXITCODE -eq 0) { $n++ } }; \
|
||||||
|
Write-Output ($g.Count.ToString() + ' ' + $n)";
|
||||||
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
|
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
|
||||||
// System32 — with a bare-name fallback.
|
// System32 — with a bare-name fallback.
|
||||||
let ps = std::env::var("SystemRoot")
|
let ps = std::env::var("SystemRoot")
|
||||||
@@ -125,17 +138,29 @@ fn reap_ghost_monitors() -> u32 {
|
|||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
Ok(o) => {
|
Ok(o) => {
|
||||||
let n = String::from_utf8_lossy(&o.stdout)
|
let raw = String::from_utf8_lossy(&o.stdout);
|
||||||
.trim()
|
let Some((found, removed)) = parse_reap_output(&raw) else {
|
||||||
.parse::<u32>()
|
|
||||||
.unwrap_or(0);
|
|
||||||
if n > 0 {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
reaped = n,
|
output = %raw.trim(),
|
||||||
|
"pf-vdisplay: ghost-monitor reap died before reporting — ghost nodes (if any) still pin IddCx monitor slots"
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if found == 0 {
|
||||||
|
tracing::info!("pf-vdisplay: no ghost (not-present) virtual-monitor nodes to reap");
|
||||||
|
} else if removed < found {
|
||||||
|
tracing::warn!(
|
||||||
|
found,
|
||||||
|
removed,
|
||||||
|
"pf-vdisplay: ghost-monitor reap could NOT remove every ghost node — the leftovers keep pinning IddCx monitor slots toward the 0x80070490 wedge"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
reaped = removed,
|
||||||
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
|
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
n
|
removed
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
|
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
|
||||||
@@ -144,6 +169,18 @@ fn reap_ghost_monitors() -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse [`reap_ghost_monitors`]'s script output — `"<found> <removed>"`. Split out to be testable
|
||||||
|
/// without a box, like [`classify_reload_output`]: the field failure this answers was a reap whose
|
||||||
|
/// outcome could not be decoded from the log at all, so the decoding is worth pinning down. `None`
|
||||||
|
/// = the script died before reporting (callers treat that as "removed nothing", loudly).
|
||||||
|
fn parse_reap_output(out: &str) -> Option<(u32, u32)> {
|
||||||
|
let mut it = out.split_whitespace().map(str::parse::<u32>);
|
||||||
|
match (it.next(), it.next()) {
|
||||||
|
(Some(Ok(found)), Some(Ok(removed))) => Some((found, removed)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
/// What an adapter-cycle attempt actually DID — deliberately NOT the devnode's PnP status afterwards.
|
||||||
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
/// The old script reported that status, and a device it had failed to touch at all still reads `OK`,
|
||||||
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
/// so a no-op cycle was indistinguishable from a real one in the log (field report 2026-08-02: a
|
||||||
@@ -178,6 +215,14 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
|||||||
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
// device description — locale-invariant). Same spawn shape as `reap_ghost_monitors` above; the
|
||||||
// reported tokens are ours, so parsing them is locale-invariant too.
|
// reported tokens are ours, so parsing them is locale-invariant too.
|
||||||
//
|
//
|
||||||
|
// The selector prefers LIVE devnodes: `Get-PnpDevice` also lists not-present PHANTOMS (an
|
||||||
|
// upgrade/reinstall leftover), and the old `Select-Object -First 1` could hand every recovery
|
||||||
|
// attempt a phantom — whose disable AND restart both fail — while a live node sat unexamined.
|
||||||
|
// A phantom-only state gets its own truthful refusal: no reload lever can revive a devnode
|
||||||
|
// record whose device is GONE; only re-creating the node (reinstall) can. `Present` is the
|
||||||
|
// authoritative bit, with `Status -ne 'Unknown'` as the fallback should it read null; live
|
||||||
|
// `OK` nodes sort ahead of problem-state ones.
|
||||||
|
//
|
||||||
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
// Every step that can fail is `-ErrorAction Stop` inside a `try` — the old script ran the whole
|
||||||
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
// cycle under `SilentlyContinue` and then reported `(Get-PnpDevice …).Status`, which reports the
|
||||||
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
// DEVICE, not the cycle: a disable that was refused left the device untouched, started, and
|
||||||
@@ -188,10 +233,19 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
|||||||
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
// let "never ran" read as "returned 0". Pre-seeding a failure means only a real exit 0 reports a
|
||||||
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
// reload. pnputil is resolved by full path — a LocalSystem service's PATH need not include
|
||||||
// System32.
|
// System32.
|
||||||
|
//
|
||||||
|
// The REFUSED line carries the evidence a field log needs to tell the failure modes apart
|
||||||
|
// (2026-08-08: a woken box logged only `REFUSED Generic failure` — the WMI catch-all — leaving
|
||||||
|
// handle-veto vs phantom vs problem-state undecidable): how many devnodes matched and how many
|
||||||
|
// are live, the chosen node's PnP Status + ConfigManager problem code, and the pnputil
|
||||||
|
// /restart-device exit code the old script threw away (3010 = needs a reboot, which is its own
|
||||||
|
// diagnosis).
|
||||||
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
const CYCLE_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
|
||||||
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
|
$all = @(Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' }); \
|
||||||
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
|
if ($all.Count -eq 0) { Write-Output 'ABSENT'; exit }; \
|
||||||
$id = $ad.InstanceId; $err = ''; \
|
$live = @($all | Where-Object { $_.Present -or $_.Status -ne 'Unknown' } | Sort-Object { $_.Status -ne 'OK' }); \
|
||||||
|
if ($live.Count -eq 0) { Write-Output ('REFUSED only phantom (not-present) adapter devnodes remain (' + $all.Count + ') - the device node itself is gone and no reload can revive it; reinstalling the host re-creates it'); exit }; \
|
||||||
|
$ad = $live[0]; $id = $ad.InstanceId; $err = ''; \
|
||||||
try { \
|
try { \
|
||||||
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
Disable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop; Start-Sleep -Seconds 2; \
|
||||||
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
try { Enable-PnpDevice -InstanceId $id -Confirm:$false -ErrorAction Stop } \
|
||||||
@@ -201,9 +255,11 @@ fn reload_vdisplay_adapter() -> AdapterCycle {
|
|||||||
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
} catch { $err = ($_.Exception.Message -replace '\\s+', ' ') }; \
|
||||||
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
|
||||||
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
if (Test-Path $pnp) { & $pnp /restart-device $id *> $null }; \
|
||||||
if ($LASTEXITCODE -eq 0) { Start-Sleep -Seconds 2; \
|
$rx = $LASTEXITCODE; \
|
||||||
|
if ($rx -eq 0) { Start-Sleep -Seconds 2; \
|
||||||
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
Write-Output ('RELOADED restart ' + (Get-PnpDevice -InstanceId $id).Status) } \
|
||||||
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; Write-Output ('REFUSED ' + $err) }";
|
else { Enable-PnpDevice -InstanceId $id -Confirm:$false; \
|
||||||
|
Write-Output ('REFUSED devnodes=' + $all.Count + ' live=' + $live.Count + ' status=' + $ad.Status + ' problem=' + $ad.ConfigManagerErrorCode + ' restart_exit=' + $rx + ' ' + $err) }";
|
||||||
let ps = std::env::var("SystemRoot")
|
let ps = std::env::var("SystemRoot")
|
||||||
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||||||
@@ -1050,10 +1106,12 @@ const BRIEF_RETRY: Duration = Duration::from_secs(3);
|
|||||||
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
/// them rather than N interleaved ones — each of which tears down the stack the others are waiting
|
||||||
/// on. The second caller through typically finds the interface already up and returns at once.
|
/// on. The second caller through typically finds the interface already up and returns at once.
|
||||||
///
|
///
|
||||||
/// Taken ONLY by [`ensure_available`], which holds no manager lock, and released before the retire
|
/// Taken ONLY by [`ensure_available`], which holds no manager lock. The lock order is one-way —
|
||||||
/// hook below takes the manager's `device` mutex. That is what keeps the lock order one-way:
|
/// `RECOVERY` → `device`: the recovery's handle-release hooks (`invalidate_cached_device`, which
|
||||||
/// [`VdisplayDriver::open`] runs *inside* that same `device` mutex, so if it could also take this
|
/// drops the manager's reference so the control handle can CLOSE before the PnP cycle) take the
|
||||||
/// lock the two orders would invert and deadlock. It cannot — it never reloads.
|
/// `device` mutex while this is held. It must stay one-way: [`VdisplayDriver::open`] runs *inside*
|
||||||
|
/// that same `device` mutex, so if it could also take this lock the two orders would invert and
|
||||||
|
/// deadlock. It cannot — it never reloads.
|
||||||
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
static RECOVERY: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
/// [`is_available`], with self-heal — and with PATIENCE, which is the part that matters after a
|
||||||
@@ -1069,10 +1127,11 @@ pub fn ensure_available() -> Result<()> {
|
|||||||
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
let _serialize = RECOVERY.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
wait_for_interface(NOT_READY_GRACE, true)
|
wait_for_interface(NOT_READY_GRACE, true)
|
||||||
};
|
};
|
||||||
// OUTSIDE the recovery lock, by the ordering contract on `RECOVERY`. A reload tore the driver
|
// A reload tore the driver stack down and back up, so any control handle cached MEANWHILE (a
|
||||||
// stack down and back up, so any control handle a previous session cached is dead by
|
// racing open during the arrival window) is dead by construction — retire it while we know
|
||||||
// construction — retire it while we know that for certain, rather than leaving the next session
|
// that for certain, rather than leaving the next session to discover it by having an IOCTL
|
||||||
// to discover it by having an IOCTL fail. No-op before any backend opened the device.
|
// fail. Usually a no-op now: the recovery path already released the manager's reference
|
||||||
|
// before the reload (the handle-drain that lets the PnP cycle proceed at all).
|
||||||
if reloaded {
|
if reloaded {
|
||||||
super::manager::invalidate_cached_device(
|
super::manager::invalidate_cached_device(
|
||||||
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
"the pf-vdisplay adapter was reloaded (hostless-zombie recovery)",
|
||||||
@@ -1119,12 +1178,33 @@ fn wait_for_interface(not_ready_grace: Duration, reload: bool) -> (Result<OwnedH
|
|||||||
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
// Track how long we have seen NOTHING. Reset by any sighting, so a device that flickers
|
||||||
// between absent and not-ready is treated as the transition it is.
|
// between absent and not-ready is treated as the transition it is.
|
||||||
if probe.is_absent() {
|
if probe.is_absent() {
|
||||||
|
if absent_since.is_none() && reload {
|
||||||
|
// First absent sighting on the recovery path: drop the manager's reference to the
|
||||||
|
// (dead) control device NOW, so the ABSENT_SETTLE below doubles as the drain window
|
||||||
|
// for every outstanding `Arc` clone — the handle then actually CLOSES before the
|
||||||
|
// reload runs. An open control handle is exactly what vetoes the PnP disable (and
|
||||||
|
// can wedge the pnputil restart) that the reload leans on; reset-pf-vdisplay.ps1
|
||||||
|
// stops the whole host service to get the same release (field 2026-08-08: every
|
||||||
|
// reload on a woken box came back REFUSED `Generic failure`). Gated on `reload`:
|
||||||
|
// the BRIEF_RETRY caller runs inside the manager's `device` mutex, where taking it
|
||||||
|
// again would deadlock — and that caller never reloads anyway.
|
||||||
|
super::manager::invalidate_cached_device(
|
||||||
|
"control interface absent — releasing the host's own device handle ahead of a \
|
||||||
|
possible adapter reload",
|
||||||
|
);
|
||||||
|
}
|
||||||
absent_since.get_or_insert_with(Instant::now);
|
absent_since.get_or_insert_with(Instant::now);
|
||||||
} else {
|
} else {
|
||||||
absent_since = None;
|
absent_since = None;
|
||||||
}
|
}
|
||||||
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
let absent_long_enough = absent_since.is_some_and(|t| t.elapsed() >= ABSENT_SETTLE);
|
||||||
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
if reload && !reloaded && (absent_long_enough || Instant::now() >= deadline) {
|
||||||
|
// The not-ready path reaches here without the absent-sighting release above — drop the
|
||||||
|
// manager's reference now for the same reason (idempotent: a second call is a no-op).
|
||||||
|
super::manager::invalidate_cached_device(
|
||||||
|
"adapter reload imminent — releasing the host's own device handle (open handles \
|
||||||
|
veto the PnP cycle)",
|
||||||
|
);
|
||||||
match reload_vdisplay_adapter() {
|
match reload_vdisplay_adapter() {
|
||||||
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
// No devnode at all — waiting cannot conjure a driver. Fail immediately rather than
|
||||||
// burning the arrival window on a box that simply does not have it installed.
|
// burning the arrival window on a box that simply does not have it installed.
|
||||||
@@ -1195,6 +1275,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A refusal must carry evidence, not just a verdict. The 2026-08-08 field log showed only
|
||||||
|
/// `REFUSED Generic failure` — the WMI catch-all — leaving handle-veto vs phantom vs
|
||||||
|
/// problem-state undecidable from the log. The enriched line's tokens (devnode counts, PnP
|
||||||
|
/// status, problem code, the pnputil restart exit code the old script discarded) must survive
|
||||||
|
/// decoding verbatim, and the phantom-only state must decode as a refusal too — a reload
|
||||||
|
/// cannot revive a devnode record whose device is gone.
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_keeps_its_evidence() {
|
||||||
|
let why = match classify_reload_output(
|
||||||
|
"REFUSED devnodes=2 live=1 status=OK problem=0 restart_exit=3010 Generic failure",
|
||||||
|
) {
|
||||||
|
AdapterCycle::Refused(why) => why,
|
||||||
|
other => panic!("expected Refused, got {}", variant(&other)),
|
||||||
|
};
|
||||||
|
for token in ["devnodes=2", "live=1", "status=OK", "restart_exit=3010"] {
|
||||||
|
assert!(why.contains(token), "{token} must survive: {why:?}");
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
classify_reload_output(
|
||||||
|
"REFUSED only phantom (not-present) adapter devnodes remain (2) - the device node \
|
||||||
|
itself is gone and no reload can revive it; reinstalling the host re-creates it"
|
||||||
|
),
|
||||||
|
AdapterCycle::Refused(why) if why.contains("phantom")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
/// The outcomes callers branch on: `NotInstalled` fails a session fast, `Reloaded` earns the
|
||||||
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
/// arrival window, and the lever that worked stays visible in the log (`restart` means the
|
||||||
/// disable was refused and something still holds the device open).
|
/// disable was refused and something still holds the device open).
|
||||||
@@ -1226,6 +1332,29 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reap's outcome must decode losslessly — the field ratchet (0.23→0.25) was a reap whose
|
||||||
|
/// bare-named pnputil never launched under the LocalSystem PATH while the host stayed silent:
|
||||||
|
/// "no ghosts" and "removed nothing" were byte-identical. Found and removed now travel
|
||||||
|
/// separately so a leftover ghost is loud, and the old single-number output (or a powershell
|
||||||
|
/// that died before reporting) must not decode as anything.
|
||||||
|
#[test]
|
||||||
|
fn reap_output_decodes_found_and_removed() {
|
||||||
|
assert_eq!(parse_reap_output("3 3\r\n"), Some((3, 3)));
|
||||||
|
assert_eq!(
|
||||||
|
parse_reap_output("4 0"),
|
||||||
|
Some((4, 0)),
|
||||||
|
"pnputil unlaunchable"
|
||||||
|
);
|
||||||
|
assert_eq!(parse_reap_output("0 0"), Some((0, 0)), "clean box");
|
||||||
|
for dead in ["5", "", " ", "garbage", "OK"] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_reap_output(dead),
|
||||||
|
None,
|
||||||
|
"{dead:?} is not a reap report"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
/// `is_absent` is what decides between WAITING and performing device surgery, so the two states
|
||||||
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
/// it separates are pinned here. An interface that is registered but not yet ACTIVE is a devnode
|
||||||
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
/// mid-transition — the wake-from-sleep case — and reloading the adapter under it only lengthens
|
||||||
|
|||||||
@@ -194,27 +194,29 @@ pub fn capture_virtual_output(
|
|||||||
crate::inject::set_stream_target(Some(target.target_id));
|
crate::inject::set_stream_target(Some(target.target_id));
|
||||||
let pref = vout.preferred_mode;
|
let pref = vout.preferred_mode;
|
||||||
let keep = vout.keepalive;
|
let keep = vout.keepalive;
|
||||||
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is
|
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE and wrap
|
||||||
// process-global — a dead one is retired, kept alive — so the raw value is stable for the
|
// `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls at ring attach.
|
||||||
// process) and wrap `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls
|
// This is the ONE reach into `crate::vdisplay` the capturer would otherwise make; building it
|
||||||
// at ring attach. This is the ONE reach into `crate::vdisplay` the capturer would otherwise make;
|
// here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
|
||||||
// building it here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
|
|
||||||
let control = crate::vdisplay::manager::control_device_handle().ok_or_else(|| {
|
let control = crate::vdisplay::manager::control_device_handle().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"pf-vdisplay control device not open (monitor not created via the manager?)"
|
"pf-vdisplay control device not open (monitor not created via the manager?)"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
// `HANDLE` is not `Send`; capture the raw value and rebuild it inside the closure (the control
|
// Each closure keeps its own `Arc<OwnedHandle>` clone (`Send + Sync`), so the handle is open
|
||||||
// device is never closed for the process lifetime, so the value stays valid).
|
// for exactly as long as any delivery closure lives — and CLOSES once the manager retires it
|
||||||
let control_raw = control.0 as isize;
|
// and the last session drops, which is what lets the wake-from-sleep recovery's PnP device
|
||||||
|
// cycle proceed (an open control handle vetoes it).
|
||||||
|
let control_frame = control.clone();
|
||||||
let sender: pf_capture::FrameChannelSender = std::sync::Arc::new(
|
let sender: pf_capture::FrameChannelSender = std::sync::Arc::new(
|
||||||
move |req: &pf_driver_proto::control::SetFrameChannelRequest| {
|
move |req: &pf_driver_proto::control::SetFrameChannelRequest| {
|
||||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is never
|
// SAFETY: the captured `control_frame` Arc keeps the control handle open across this
|
||||||
// closed for the process lifetime, so reconstructing the `HANDLE` and issuing the
|
// call — `send_frame_channel`'s precondition.
|
||||||
// `IOCTL_SET_FRAME_CHANNEL` is sound (`send_frame_channel`'s precondition).
|
|
||||||
unsafe {
|
unsafe {
|
||||||
crate::vdisplay::driver::send_frame_channel(
|
crate::vdisplay::driver::send_frame_channel(
|
||||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
windows::Win32::Foundation::HANDLE(
|
||||||
|
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_frame),
|
||||||
|
),
|
||||||
req,
|
req,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -231,14 +233,17 @@ pub fn capture_virtual_output(
|
|||||||
// Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure —
|
// Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure —
|
||||||
// its presence opts the session in (the capturer creates + delivers the CursorShm section,
|
// its presence opts the session in (the capturer creates + delivers the CursorShm section,
|
||||||
// the driver declares the IddCx hardware cursor). Built exactly like `sender` above.
|
// the driver declares the IddCx hardware cursor). Built exactly like `sender` above.
|
||||||
|
let control_cursor = control.clone();
|
||||||
let cursor_sender: Option<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
|
let cursor_sender: Option<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
|
||||||
std::sync::Arc::new(
|
std::sync::Arc::new(
|
||||||
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
|
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
|
||||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
// SAFETY: the captured `control_cursor` Arc keeps the control handle open across
|
||||||
// never closed for the process lifetime (`send_cursor_channel`'s precondition).
|
// this call (`send_cursor_channel`'s precondition).
|
||||||
unsafe {
|
unsafe {
|
||||||
crate::vdisplay::driver::send_cursor_channel(
|
crate::vdisplay::driver::send_cursor_channel(
|
||||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
windows::Win32::Foundation::HANDLE(
|
||||||
|
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_cursor),
|
||||||
|
),
|
||||||
req,
|
req,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -261,11 +266,13 @@ pub fn capture_virtual_output(
|
|||||||
target_id,
|
target_id,
|
||||||
enable: enable as u32,
|
enable: enable as u32,
|
||||||
};
|
};
|
||||||
// SAFETY: `control_raw` is the pf-vdisplay control handle resolved above; it is
|
// SAFETY: the captured `control` Arc keeps the control handle open across this call
|
||||||
// never closed for the process lifetime (`send_cursor_forward`'s precondition).
|
// (`send_cursor_forward`'s precondition).
|
||||||
unsafe {
|
unsafe {
|
||||||
crate::vdisplay::driver::send_cursor_forward(
|
crate::vdisplay::driver::send_cursor_forward(
|
||||||
windows::Win32::Foundation::HANDLE(control_raw as *mut core::ffi::c_void),
|
windows::Win32::Foundation::HANDLE(
|
||||||
|
std::os::windows::io::AsRawHandle::as_raw_handle(&*control),
|
||||||
|
),
|
||||||
&req,
|
&req,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ mod hidden;
|
|||||||
mod launch;
|
mod launch;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod lutris;
|
mod lutris;
|
||||||
|
mod plugin_launch;
|
||||||
mod scanners;
|
mod scanners;
|
||||||
mod steam;
|
mod steam;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -51,6 +52,7 @@ pub use hidden::*;
|
|||||||
pub use launch::*;
|
pub use launch::*;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use lutris::*;
|
pub use lutris::*;
|
||||||
|
pub use plugin_launch::*;
|
||||||
pub use scanners::*;
|
pub use scanners::*;
|
||||||
pub use steam::*;
|
pub use steam::*;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|||||||
@@ -476,6 +476,15 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
|||||||
"entries[{i}]: `launch.value` for kind `xbox` must be `<Identity>!<AppId>`"
|
"entries[{i}]: `launch.value` for kind `xbox` must be `<Identity>!<AppId>`"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// `plugin`: the value is an opaque key in the OWNING plugin's own namespace, handed back
|
||||||
|
// to it at launch time (see `library::ask_plugin_launch`). The host never parses it, so
|
||||||
|
// the only checks are the ones that keep it loggable and bounded.
|
||||||
|
if launch.kind == "plugin" && !valid_plugin_entry_key(&launch.value) {
|
||||||
|
return Err(format!(
|
||||||
|
"entries[{i}]: `launch.value` for kind `plugin` must be 1–512 chars with no \
|
||||||
|
control characters"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(marker) = &e.detect.env_marker {
|
if let Some(marker) = &e.detect.env_marker {
|
||||||
if !valid_env_key(&marker.key) {
|
if !valid_env_key(&marker.key) {
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
|||||||
{
|
{
|
||||||
// Linux runs the command itself, so a title without one has nothing to launch — same answer
|
// Linux runs the command itself, so a title without one has nothing to launch — same answer
|
||||||
// (and same warning path) as before this resolution existed.
|
// (and same warning path) as before this resolution existed.
|
||||||
let command = entry.launch.as_ref().and_then(command_for)?;
|
let command = plugin_recipe(&entry)
|
||||||
|
.map(|l| l.command)
|
||||||
|
.or_else(|| entry.launch.as_ref().and_then(command_for))?;
|
||||||
Some(LaunchTarget {
|
Some(LaunchTarget {
|
||||||
game,
|
game,
|
||||||
launcher: entry.role == GameRole::Launcher,
|
launcher: entry.role == GameRole::Launcher,
|
||||||
@@ -74,9 +76,66 @@ pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The recipe for a `plugin`-kind entry, asked of the plugin that owns it. `None` for every other
|
||||||
|
/// kind (without doing any I/O), so both per-OS resolvers can simply try this first.
|
||||||
|
///
|
||||||
|
/// This lives beside [`resolve_launch`] / [`launch_title`] rather than inside `command_for` /
|
||||||
|
/// `windows_launch_for` because it needs the entry's **`provider`** — and that field is the whole
|
||||||
|
/// authorization story. `provider` is stamped by the host from the reconcile URL
|
||||||
|
/// (`PUT /library/provider/{provider}`), never taken from the payload, so it is what decides which
|
||||||
|
/// plugin gets asked. A plugin that plants an entry under someone else's provider only causes that
|
||||||
|
/// *other* plugin to be asked about a key it never published — which is a 404, not a launch.
|
||||||
|
///
|
||||||
|
/// **Blocking**: see [`ask_plugin_launch`]. `resolve_launch`'s async callers hop through
|
||||||
|
/// `spawn_blocking`; the handshake probe uses [`launch_is_resolvable`], which never asks.
|
||||||
|
fn plugin_recipe(entry: &GameEntry) -> Option<PluginLaunch> {
|
||||||
|
let spec = entry.launch.as_ref()?;
|
||||||
|
if spec.kind != "plugin" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(provider) = entry.provider.as_deref() else {
|
||||||
|
// Only a provider reconcile can author this kind, so this is unreachable short of a
|
||||||
|
// hand-edited library.json — say so rather than silently doing nothing.
|
||||||
|
tracing::warn!(
|
||||||
|
id = %entry.id,
|
||||||
|
"plugin launch: entry carries no provider, so no plugin can answer for it"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
ask_plugin_launch(provider, &spec.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `id` will actually launch something — **without asking a plugin**.
|
||||||
|
///
|
||||||
|
/// The handshake needs this one bit to decide dedicated-session routing, and it runs on the async
|
||||||
|
/// path, so it must not make a blocking call out to a plugin. For a `plugin`-kind entry the cheap
|
||||||
|
/// answer is "a live plugin is registered under its provider, and the key is well formed"; if that
|
||||||
|
/// plugin later refuses the ask, the launch fails the same way any unresolvable entry does and the
|
||||||
|
/// player is left on the session.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn launch_is_resolvable(id: &str) -> bool {
|
||||||
|
let Some(entry) = all_games().into_iter().find(|g| g.id == id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(spec) = entry.launch.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if spec.kind == "plugin" {
|
||||||
|
return valid_plugin_entry_key(&spec.value)
|
||||||
|
&& entry
|
||||||
|
.provider
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|p| crate::mgmt::ui_credential(p).is_some());
|
||||||
|
}
|
||||||
|
command_for(spec).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
|
||||||
/// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install).
|
/// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install).
|
||||||
///
|
///
|
||||||
|
/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is
|
||||||
|
/// resolved by [`plugin_recipe`] before this is reached.
|
||||||
|
///
|
||||||
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
|
||||||
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
|
||||||
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
|
||||||
@@ -126,17 +185,24 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
|
|||||||
/// desktop and grabs foreground.
|
/// desktop and grabs foreground.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn launch_title(id: &str) -> Result<()> {
|
pub fn launch_title(id: &str) -> Result<()> {
|
||||||
let spec = all_games()
|
let entry = all_games()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|g| g.id == id)
|
.find(|g| g.id == id)
|
||||||
.and_then(|g| g.launch)
|
.filter(|g| g.launch.is_some())
|
||||||
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
|
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
|
||||||
let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| {
|
let spec = entry.launch.clone().expect("filtered to Some above");
|
||||||
anyhow::anyhow!(
|
// A `plugin` entry's recipe comes from the plugin that owns it, and arrives in the same
|
||||||
"library entry '{id}' has no Windows launch recipe (kind '{}')",
|
// (command line, working dir) shape this path already spawns. `windows_launch_for` has no arm
|
||||||
spec.kind
|
// for the kind, so a failed ask falls through to the "no recipe" error below.
|
||||||
)
|
let (cmdline, workdir) = plugin_recipe(&entry)
|
||||||
})?;
|
.map(|l| (l.command, l.cwd))
|
||||||
|
.or_else(|| windows_launch_for(&spec))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"library entry '{id}' has no Windows launch recipe (kind '{}')",
|
||||||
|
spec.kind
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
|
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
|
||||||
.with_context(|| format!("launch '{id}' in the interactive session"))?;
|
.with_context(|| format!("launch '{id}' in the interactive session"))?;
|
||||||
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
|
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
|
||||||
@@ -148,6 +214,9 @@ pub fn launch_title(id: &str) -> Result<()> {
|
|||||||
///
|
///
|
||||||
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
|
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
|
||||||
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
|
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
|
||||||
|
///
|
||||||
|
/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is
|
||||||
|
/// resolved by [`plugin_recipe`] before this is reached.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
|
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
|
||||||
match spec.kind.as_str() {
|
match spec.kind.as_str() {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
//! The `plugin` launch kind's transport: ask a library plugin what to run for one of **its own**
|
||||||
|
//! entries, at launch time, over the loopback UI surface it already registered.
|
||||||
|
//!
|
||||||
|
//! ## Why the host asks instead of storing a command
|
||||||
|
//!
|
||||||
|
//! A ROM tile is `<emulator> <args> <rom>` — an operator-configured command line, and the one shape
|
||||||
|
//! [`super::privileged_field`] refuses from the plugin lane (2026-08-05 review H-1). The Playnite
|
||||||
|
//! plugin hit the same wall and was rescued with a typed `playnite` kind the host resolves itself
|
||||||
|
//! (see `command_for`), but that only works because a Playnite launch is a fixed URI scheme. There
|
||||||
|
//! is no fixed scheme for "some emulator the operator installed, with the core and flags they chose"
|
||||||
|
//! — the knowledge lives in the plugin, and it is the plugin that owns the hardened quoting seam for
|
||||||
|
//! it (ROM filenames are untrusted input).
|
||||||
|
//!
|
||||||
|
//! So the entry carries an **opaque key** and nothing executable, and the command is fetched from
|
||||||
|
//! the owning plugin at the moment of an actual launch. What that buys over letting the plugin write
|
||||||
|
//! `kind = "command"` straight into the library:
|
||||||
|
//!
|
||||||
|
//! * **A stolen plugin token is no longer command execution.** Planting an entry is not enough — the
|
||||||
|
//! host asks the *live registered plugin* what to run, authenticated with the per-boot secret only
|
||||||
|
//! that process knows. A plugin asked about an entry it never published answers 404 (this is why
|
||||||
|
//! the ask names the entry rather than trusting the payload), so a forged entry launches nothing.
|
||||||
|
//! * **Nothing executable is ever persisted or served.** No command lands in `library.json`, and
|
||||||
|
//! `GET /library` has none to redact for a paired client.
|
||||||
|
//! * **No stale recipes.** The same reasoning as the `xbox` kind resolving its AUMID at launch time:
|
||||||
|
//! an emulator that moved, or a config the operator has since edited, is picked up on the next
|
||||||
|
//! launch instead of leaving an unlaunchable tile behind.
|
||||||
|
//!
|
||||||
|
//! The host still *runs* the command, because only the host can put the process where the stream can
|
||||||
|
//! see it: on Linux the line is either gamescope's own argv (a bare-spawn session nests it) or a
|
||||||
|
//! spawn carrying the session's compositor env, and the returned child is what
|
||||||
|
//! `design/session-game-lifetime.md` tracks to know the game exited. A plugin spawning the emulator
|
||||||
|
//! itself would land it outside the captured session and outside that lifetime.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The whole ask, end to end. A plugin resolving one of its own entries is a local lookup against
|
||||||
|
/// state it already holds, so this is generous for a healthy plugin and short enough that a wedged
|
||||||
|
/// one cannot hold a launch — or, on the GameStream plane, the data-plane thread that calls this —
|
||||||
|
/// for longer than a player would keep staring at a tile that did nothing.
|
||||||
|
const ASK_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// A command LINE, not a script. Generous for `flatpak run … --core=… "/very/long/rom path"`,
|
||||||
|
/// bounded so a malformed answer cannot land a megabyte in the logs or in a shell argument.
|
||||||
|
const MAX_COMMAND: usize = 4096;
|
||||||
|
|
||||||
|
/// Cap the whole response body — the shape is two short strings.
|
||||||
|
const MAX_BODY: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// What a plugin answered: the command line to run, and optionally the directory to run it in
|
||||||
|
/// (emulators that resolve cores or configs relative to their install dir need one).
|
||||||
|
pub struct PluginLaunch {
|
||||||
|
pub command: String,
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wire shape of `POST /__launch`'s response.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LaunchReply {
|
||||||
|
command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
cwd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opaque per-entry key a `plugin` launch carries. It is echoed to the owning plugin as JSON and
|
||||||
|
/// lands in log lines, so bound it and keep control characters out; everything else is the plugin's
|
||||||
|
/// own namespace (rom-manager uses its `<platform>/<relpath>` external id).
|
||||||
|
pub fn valid_plugin_entry_key(v: &str) -> bool {
|
||||||
|
!v.is_empty() && v.len() <= 512 && !v.chars().any(char::is_control)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask `plugin` what to run for its entry `key`.
|
||||||
|
///
|
||||||
|
/// `None` — the plugin is not registered/live, has no UI surface, disowns the entry, or answered
|
||||||
|
/// something unusable. Every arm logs, because from a player's seat all of them look like "the tile
|
||||||
|
/// did nothing", and the difference is exactly what an operator needs to fix it.
|
||||||
|
///
|
||||||
|
/// **Blocking** (`ureq`, the host's existing off-runtime HTTP client): callers run on a blocking
|
||||||
|
/// thread. `resolve_launch`'s async callers hop through `spawn_blocking`, and the handshake's
|
||||||
|
/// "is this launchable at all" probe uses [`super::launch_is_resolvable`], which never asks.
|
||||||
|
pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option<PluginLaunch> {
|
||||||
|
if !valid_plugin_entry_key(key) {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
"plugin launch: entry key failed validation — ignoring"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(cred) = crate::mgmt::ui_credential(plugin) else {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
"plugin launch: no live plugin registered under that provider id (is it running?) — \
|
||||||
|
nothing to launch"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let agent = ureq::AgentBuilder::new().timeout(ASK_TIMEOUT).build();
|
||||||
|
// Loopback + the plugin's own per-boot secret, exactly what the console proxy presents. The
|
||||||
|
// registration stores a PORT, never an address (mgmt::plugins D5), so this can only ever dial
|
||||||
|
// this machine.
|
||||||
|
// `send_string` + an explicit content type rather than `send_json`: that one needs ureq's `json`
|
||||||
|
// feature, and the body is one field.
|
||||||
|
let body = serde_json::json!({ "entry": key }).to_string();
|
||||||
|
let resp = match agent
|
||||||
|
.post(&format!("http://127.0.0.1:{}/__launch", cred.port))
|
||||||
|
.set("Authorization", &format!("Bearer {}", cred.secret))
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.send_string(&body)
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
// A plugin that does not know the entry says so with a 404 — the answer a FORGED entry gets,
|
||||||
|
// and the reason planting one is not enough to make the host run anything.
|
||||||
|
Err(ureq::Error::Status(404, _)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
"plugin launch: the plugin does not own an entry with that key — nothing to launch"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Err(ureq::Error::Status(code, _)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
code,
|
||||||
|
"plugin launch: the plugin refused to resolve the entry"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
error = %e,
|
||||||
|
"plugin launch: could not reach the plugin's launch surface"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
if let Err(e) = resp
|
||||||
|
.into_reader()
|
||||||
|
.take((MAX_BODY + 1) as u64)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
{
|
||||||
|
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if buf.len() > MAX_BODY {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
"plugin launch: answer exceeds the {MAX_BODY}-byte cap"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let reply: LaunchReply = match serde_json::from_slice(&buf) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(plugin, entry = key, error = %e, "plugin launch: answer was not {{command, cwd}}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
validate_reply(plugin, key, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The checks on what came back, split out so they can be tested without a plugin on a port.
|
||||||
|
fn validate_reply(plugin: &str, key: &str, reply: LaunchReply) -> Option<PluginLaunch> {
|
||||||
|
let command = reply.command.trim().to_string();
|
||||||
|
if command.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
"plugin launch: answered an empty command"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if command.len() > MAX_COMMAND {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
"plugin launch: command exceeds the {MAX_COMMAND}-byte cap"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Hygiene rather than a security boundary — a plugin that wanted two commands could always write
|
||||||
|
// `a; b`, and composing the line is its job. But a launch command is ONE line: keeping control
|
||||||
|
// characters out is what makes the logged line the line that ran, and what stops a stray `\r`
|
||||||
|
// from mangling the Windows `cmd.exe /c` form.
|
||||||
|
if command.chars().any(char::is_control) {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
"plugin launch: command contains control characters — refusing it"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cwd = match reply
|
||||||
|
.cwd
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|c| !c.is_empty())
|
||||||
|
{
|
||||||
|
None => None,
|
||||||
|
Some(dir) => {
|
||||||
|
let path = PathBuf::from(dir);
|
||||||
|
// Relative to WHAT? The host's cwd is not the plugin's, and a launch that silently ran
|
||||||
|
// somewhere unintended is worse than one that says why it did not.
|
||||||
|
if !path.is_absolute() {
|
||||||
|
tracing::warn!(
|
||||||
|
plugin,
|
||||||
|
entry = key,
|
||||||
|
cwd = dir,
|
||||||
|
"plugin launch: working directory must be absolute — refusing it"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(PluginLaunch { command, cwd })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// A one-shot HTTP/1.1 stub on an ephemeral loopback port. Returns the port and a handle that
|
||||||
|
/// yields the raw request text — so the assertions about what the HOST sent (method, path,
|
||||||
|
/// bearer, body) live in the test thread, where a failure reads as a failure.
|
||||||
|
fn stub_plugin(status: u16, body: &'static str) -> (u16, std::thread::JoinHandle<String>) {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback");
|
||||||
|
let port = listener.local_addr().expect("local addr").port();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let (mut sock, _) = listener.accept().expect("accept");
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
// Read until the body named by Content-Length has arrived (ureq always sends one here).
|
||||||
|
loop {
|
||||||
|
let n = sock.read(&mut chunk).expect("read request");
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
let text = String::from_utf8_lossy(&buf).to_string();
|
||||||
|
if let Some(end) = text.find("\r\n\r\n") {
|
||||||
|
let len = text[..end]
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| {
|
||||||
|
let (k, v) = l.split_once(':')?;
|
||||||
|
k.eq_ignore_ascii_case("content-length")
|
||||||
|
.then(|| v.trim().parse::<usize>().ok())?
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if buf.len() >= end + 4 + len {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resp = format!(
|
||||||
|
"HTTP/1.1 {status} STATUS\r\nContent-Type: application/json\r\n\
|
||||||
|
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
sock.write_all(resp.as_bytes()).expect("write response");
|
||||||
|
let _ = sock.flush();
|
||||||
|
String::from_utf8_lossy(&buf).to_string()
|
||||||
|
});
|
||||||
|
(port, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asks_the_registered_plugin_and_takes_its_answer() {
|
||||||
|
let (port, server) =
|
||||||
|
stub_plugin(200, r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#);
|
||||||
|
crate::mgmt::register_ui_for_test("stub-launcher", port, "s3cr3t");
|
||||||
|
|
||||||
|
let got = ask_plugin_launch("stub-launcher", "snes/smw.sfc").expect("a recipe");
|
||||||
|
assert_eq!(got.command, "retroarch 'smw.sfc'");
|
||||||
|
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new("/opt/emu")));
|
||||||
|
|
||||||
|
let req = server.join().expect("stub thread");
|
||||||
|
assert!(req.starts_with("POST /__launch "), "request was {req:?}");
|
||||||
|
// The plugin's own per-boot secret, the same credential the console proxy presents.
|
||||||
|
assert!(
|
||||||
|
req.contains("Bearer s3cr3t"),
|
||||||
|
"the ask must authenticate: {req:?}"
|
||||||
|
);
|
||||||
|
// The entry key is what the plugin resolves against its own state — it must be on the wire.
|
||||||
|
assert!(
|
||||||
|
req.contains(r#""entry":"snes/smw.sfc""#),
|
||||||
|
"body was {req:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_404_means_the_plugin_disowns_the_entry() {
|
||||||
|
// The forged-entry case: planting a library row is not enough, because the plugin that would
|
||||||
|
// have to answer for it never published one.
|
||||||
|
let (port, server) = stub_plugin(404, r#"{"error":"no launchable entry \"forged\""}"#);
|
||||||
|
crate::mgmt::register_ui_for_test("stub-disowner", port, "s");
|
||||||
|
|
||||||
|
assert!(ask_plugin_launch("stub-disowner", "forged").is_none());
|
||||||
|
server.join().expect("stub thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unregistered_provider_resolves_to_nothing() {
|
||||||
|
// No live plugin, no port to dial, no launch — and no panic.
|
||||||
|
assert!(ask_plugin_launch("no-such-plugin-is-registered", "k").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(command: &str, cwd: Option<&str>) -> LaunchReply {
|
||||||
|
LaunchReply {
|
||||||
|
command: command.into(),
|
||||||
|
cwd: cwd.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entry_keys_are_bounded_and_printable() {
|
||||||
|
assert!(valid_plugin_entry_key("snes/Super Mario World.sfc"));
|
||||||
|
assert!(!valid_plugin_entry_key(""));
|
||||||
|
assert!(!valid_plugin_entry_key("with\nnewline"));
|
||||||
|
assert!(!valid_plugin_entry_key("with\0nul"));
|
||||||
|
assert!(!valid_plugin_entry_key(&"x".repeat(513)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_usable_answer_passes_through_trimmed() {
|
||||||
|
let got = validate_reply(
|
||||||
|
"rom-manager",
|
||||||
|
"snes/smw",
|
||||||
|
reply(" retroarch 'smw.sfc' \n", None),
|
||||||
|
)
|
||||||
|
.expect("usable");
|
||||||
|
assert_eq!(got.command, "retroarch 'smw.sfc'");
|
||||||
|
assert!(got.cwd.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_oversized_and_control_char_commands_are_refused() {
|
||||||
|
assert!(validate_reply("p", "k", reply(" ", None)).is_none());
|
||||||
|
assert!(validate_reply("p", "k", reply(&"x".repeat(MAX_COMMAND + 1), None)).is_none());
|
||||||
|
// The interesting one: a second line smuggled into what the host logs as a single command.
|
||||||
|
assert!(validate_reply("p", "k", reply("retroarch rom\nrm -rf ~", None)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_working_directory_must_be_absolute() {
|
||||||
|
let abs = if cfg!(windows) { r"C:\emu" } else { "/opt/emu" };
|
||||||
|
let got = validate_reply("p", "k", reply("run", Some(abs))).expect("absolute cwd is fine");
|
||||||
|
assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new(abs)));
|
||||||
|
assert!(validate_reply("p", "k", reply("run", Some("emu/cores"))).is_none());
|
||||||
|
// An empty/whitespace cwd is "no preference", not a refusal.
|
||||||
|
assert!(validate_reply("p", "k", reply("run", Some(" ")))
|
||||||
|
.expect("blank cwd is tolerated")
|
||||||
|
.cwd
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,14 @@ mod store;
|
|||||||
mod tests;
|
mod tests;
|
||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
|
/// Lets `library::plugin_launch`'s tests put a stub plugin in the registry (test-only).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use plugins::register_ui_for_test;
|
||||||
|
/// The launch path asks a library plugin what to run for its own entries, and needs the loopback
|
||||||
|
/// credential this process already holds for it. Re-exported (rather than opening the whole
|
||||||
|
/// `plugins` module crate-wide) so these two are the ONLY things `mgmt` lends to the library side.
|
||||||
|
pub(crate) use plugins::ui_credential;
|
||||||
|
|
||||||
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
||||||
/// number Sunshine users already associate with "the config UI".
|
/// number Sunshine users already associate with "the config UI".
|
||||||
pub const DEFAULT_PORT: u16 = 47990;
|
pub const DEFAULT_PORT: u16 = 47990;
|
||||||
|
|||||||
@@ -286,6 +286,38 @@ pub(crate) fn live_plugin_ids() -> Vec<String> {
|
|||||||
registry().live_ids()
|
registry().live_ids()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The loopback `{port, secret}` a live plugin serves its UI on — the credential the **host itself**
|
||||||
|
/// presents when it asks a library plugin what to run for one of its `plugin`-kind launch entries
|
||||||
|
/// ([`crate::library::ask_plugin_launch`]).
|
||||||
|
///
|
||||||
|
/// The same lookup the console proxy gets from `GET /plugins/{id}/ui-credential`, exposed in-process
|
||||||
|
/// so the launch path never round-trips through the management API to reach a port this process
|
||||||
|
/// already holds. `None` for an unknown, expired, or UI-less plugin — which the launch path reports
|
||||||
|
/// as "no recipe", exactly like any other unresolvable entry.
|
||||||
|
pub(crate) fn ui_credential(id: &str) -> Option<UiCredential> {
|
||||||
|
registry().credential(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a live UI registration in the registry directly — **test only**, so the launch path
|
||||||
|
/// ([`crate::library::ask_plugin_launch`]) can be driven against a stub server without standing up
|
||||||
|
/// the whole management router just to reach `PUT /plugins/{id}`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn register_ui_for_test(id: &str, port: u16, secret: &str) {
|
||||||
|
registry().upsert(
|
||||||
|
id,
|
||||||
|
Valid {
|
||||||
|
title: id.to_string(),
|
||||||
|
version: None,
|
||||||
|
ui: Some(StoredUi {
|
||||||
|
port,
|
||||||
|
secret: secret.to_string(),
|
||||||
|
icon: None,
|
||||||
|
}),
|
||||||
|
category: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- validation
|
// ---------------------------------------------------------------- validation
|
||||||
|
|
||||||
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
|
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
|
||||||
|
|||||||
@@ -1507,11 +1507,17 @@ async fn serve_session(
|
|||||||
// launcher's on-disk metadata, and the data plane needs three things out of it — what to run, what
|
// launcher's on-disk metadata, and the data plane needs three things out of it — what to run, what
|
||||||
// to call the title, and how to recognize its process once a launcher has handed off
|
// to call the title, and how to recognize its process once a launcher has handed off
|
||||||
// (design/session-game-lifetime.md §4).
|
// (design/session-game-lifetime.md §4).
|
||||||
let launch_target =
|
//
|
||||||
hello
|
// On a blocking thread: a `plugin`-kind entry resolves by asking the plugin that owns it over
|
||||||
.launch
|
// loopback (`library::ask_plugin_launch`), and this is an async context.
|
||||||
.as_deref()
|
let launch_target = match hello.launch.as_deref() {
|
||||||
.and_then(|id| match crate::library::resolve_launch(id) {
|
None => None,
|
||||||
|
Some(id) => {
|
||||||
|
let owned = id.to_string();
|
||||||
|
match tokio::task::spawn_blocking(move || crate::library::resolve_launch(&owned))
|
||||||
|
.await
|
||||||
|
.context("resolve the session's library launch")?
|
||||||
|
{
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
launch_id = id,
|
launch_id = id,
|
||||||
@@ -1528,7 +1534,9 @@ async fn serve_session(
|
|||||||
);
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let launch_for_dp = launch_target.as_ref().and(hello.launch.clone());
|
let launch_for_dp = launch_target.as_ref().and(hello.launch.clone());
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
|||||||
@@ -33,6 +33,52 @@ fn pick_compositor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is this connect pinned at a compositor that is not actually running?
|
||||||
|
///
|
||||||
|
/// Pure (the I/O shell passes in the observed liveness) so the interaction is unit-tested, because
|
||||||
|
/// it is invisible from the outside: an operator pin puts its backend into
|
||||||
|
/// [`crate::vdisplay::available`] unconditionally AND skips `apply_session_env`'s
|
||||||
|
/// `XDG_CURRENT_DESKTOP` scrub, so [`pick_compositor`] hands back a compositor that may be a corpse
|
||||||
|
/// and its `None` (recover) arm can never fire. [`Compositor::Gamescope`] is exempt — it stands its
|
||||||
|
/// own session up, which is the whole reason a headless box pins it.
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn pinned_at_a_dead_session(
|
||||||
|
overridden: bool,
|
||||||
|
chosen: crate::vdisplay::Compositor,
|
||||||
|
live: crate::vdisplay::ActiveKind,
|
||||||
|
) -> bool {
|
||||||
|
overridden && chosen.needs_live_session() && live == crate::vdisplay::ActiveKind::None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The handshake error for "no graphical session is live for this uid" — the state a compositor
|
||||||
|
/// crash leaves behind (gnome-shell SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the
|
||||||
|
/// box would otherwise need a walk-up or a reboot).
|
||||||
|
///
|
||||||
|
/// Fires the operator's recovery hook (debounced) on the way out when one is configured, so the
|
||||||
|
/// client's retry a few seconds later lands in a recovered desktop. `pinned` names the
|
||||||
|
/// `PUNKTFUNK_COMPOSITOR` value when the pin is what got us here, so the message can say which knob
|
||||||
|
/// to change rather than the generic advice to *set* the knob that caused it.
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn no_live_session(pinned: Option<&str>) -> anyhow::Error {
|
||||||
|
if crate::vdisplay::try_recover_session() {
|
||||||
|
return anyhow::anyhow!(
|
||||||
|
"no live graphical session for this uid — host session recovery launched \
|
||||||
|
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match pinned {
|
||||||
|
Some(pin) => anyhow::anyhow!(
|
||||||
|
"PUNKTFUNK_COMPOSITOR={pin} pins this host to a backend that can only attach to an \
|
||||||
|
already-running compositor, and no graphical session is live for this uid — start a \
|
||||||
|
session, pin `gamescope` (it stands its own up), or set PUNKTFUNK_RECOVER_SESSION_CMD"
|
||||||
|
),
|
||||||
|
None => anyhow::anyhow!(
|
||||||
|
"no usable compositor (no live graphical session for this uid; set \
|
||||||
|
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the client's compositor preference to a concrete backend (the I/O shell around
|
/// Resolve the client's compositor preference to a concrete backend (the I/O shell around
|
||||||
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
||||||
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
||||||
@@ -61,13 +107,21 @@ pub(super) fn resolve_compositor(
|
|||||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||||
// to come with a hand-set env — don't retarget the process env in that case.
|
// to come with a hand-set env — don't retarget the process env in that case.
|
||||||
let overridden = pf_host_config::config().compositor.is_some();
|
let overridden = pf_host_config::config().compositor.is_some();
|
||||||
|
// Liveness is read on BOTH paths. The auto path retargets the process env at the live
|
||||||
|
// session (below); the PINNED path needs it too, because a pin names a BACKEND, not a
|
||||||
|
// running session — and a pin whose compositor has died used to be indistinguishable from a
|
||||||
|
// healthy one here (it skips `apply_session_env`'s `XDG_CURRENT_DESKTOP` scrub and lands
|
||||||
|
// itself in `available()`, so `pick_compositor` could never return `None`). That combination
|
||||||
|
// marched every client through 8 doomed `create` retries and left the operator's
|
||||||
|
// `PUNKTFUNK_RECOVER_SESSION_CMD` unreachable — see the `needs_live_session` gate below.
|
||||||
|
let active = crate::vdisplay::detect_active_session();
|
||||||
let detected = if overridden {
|
let detected = if overridden {
|
||||||
crate::vdisplay::detect().ok()
|
crate::vdisplay::detect().ok()
|
||||||
} else {
|
} else {
|
||||||
// Auto: detect the LIVE session (Gaming vs Desktop) and retarget the process env at it so
|
// Auto: detect the LIVE session (Gaming vs Desktop) and retarget the process env at it so
|
||||||
// every backend (video capture + input) this connect opens against the active session —
|
// every backend (video capture + input) this connect opens against the active session —
|
||||||
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
||||||
let active = crate::vdisplay::detect_active_session();
|
//
|
||||||
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
|
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
|
||||||
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
|
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
|
||||||
// reuses a node id from the dead instance.
|
// reuses a node id from the dead instance.
|
||||||
@@ -84,14 +138,36 @@ pub(super) fn resolve_compositor(
|
|||||||
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
|
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
|
||||||
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
|
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
|
||||||
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
|
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
|
||||||
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
|
// backend + input to the spawn sub-mode. An explicit operator compositor pin still outranks
|
||||||
if dedicated_launch && !overridden {
|
// it — but says so out loud (below), because a silent veto is indistinguishable from the
|
||||||
let route = crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
// feature being broken.
|
||||||
tracing::info!(
|
if dedicated_launch {
|
||||||
?route,
|
if overridden {
|
||||||
"dedicated game session — routing to a headless gamescope spawn at the client mode"
|
// The pin still wins (it is the operator's explicit, hand-configured knob), but it
|
||||||
);
|
// must NEVER win silently: the console goes on displaying `game_session=dedicated`
|
||||||
return Ok((Compositor::Gamescope, route));
|
// while every launch lands in the pinned session instead, and nothing in the log
|
||||||
|
// connects the two. That cost a full triage on a box whose `PUNKTFUNK_COMPOSITOR`
|
||||||
|
// was a forgotten validation leftover — the setting had never once taken effect and
|
||||||
|
// the only evidence was the ABSENCE of the info! line below.
|
||||||
|
tracing::warn!(
|
||||||
|
pin = pf_host_config::config()
|
||||||
|
.compositor
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("-"),
|
||||||
|
"game_session=dedicated asked for this launch's OWN headless gamescope, but \
|
||||||
|
PUNKTFUNK_COMPOSITOR pins this host to a backend — the operator pin wins and \
|
||||||
|
the game launches into the pinned session instead. Unset PUNKTFUNK_COMPOSITOR \
|
||||||
|
to get dedicated game sessions."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let route = crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||||
|
tracing::info!(
|
||||||
|
?route,
|
||||||
|
"dedicated game session — routing to a headless gamescope spawn at the client \
|
||||||
|
mode"
|
||||||
|
);
|
||||||
|
return Ok((Compositor::Gamescope, route));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let available = crate::vdisplay::available();
|
let available = crate::vdisplay::available();
|
||||||
let chosen = match pick_compositor(pref, &available, detected) {
|
let chosen = match pick_compositor(pref, &available, detected) {
|
||||||
@@ -112,23 +188,18 @@ pub(super) fn resolve_compositor(
|
|||||||
);
|
);
|
||||||
Compositor::Gamescope
|
Compositor::Gamescope
|
||||||
}
|
}
|
||||||
None => {
|
None => return Err(no_live_session(None)),
|
||||||
// The state a compositor crash leaves behind (gnome-shell
|
|
||||||
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
|
||||||
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
|
||||||
// its next knock lands in the recovered desktop.
|
|
||||||
if crate::vdisplay::try_recover_session() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"no live graphical session for this uid — host session recovery launched \
|
|
||||||
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
anyhow::bail!(
|
|
||||||
"no usable compositor (no live graphical session for this uid; set \
|
|
||||||
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
// Same dead-session exit, reached the other way: a pin puts its backend in `available()`
|
||||||
|
// unconditionally, so `pick_compositor` above can hand back a compositor that is not
|
||||||
|
// actually running and the `None` arm never fires. Check the backend's own requirement
|
||||||
|
// against observed liveness instead of trusting the pin. Gamescope is exempt — it stands
|
||||||
|
// its own session up, which is the whole point of pinning it on a headless box.
|
||||||
|
if pinned_at_a_dead_session(overridden, chosen, active.kind) {
|
||||||
|
return Err(no_live_session(
|
||||||
|
pf_host_config::config().compositor.as_deref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). The
|
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). The
|
||||||
// route travels back to the caller as a VALUE and is carried on the backend instance — an
|
// route travels back to the caller as a VALUE and is carried on the backend instance — an
|
||||||
@@ -170,6 +241,44 @@ mod tests {
|
|||||||
use super::pick_compositor;
|
use super::pick_compositor;
|
||||||
use punktfunk_core::config::CompositorPref;
|
use punktfunk_core::config::CompositorPref;
|
||||||
|
|
||||||
|
/// A pin at a compositor that ISN'T RUNNING must take the recovery exit rather than march the
|
||||||
|
/// client into a bring-up that can only fail.
|
||||||
|
///
|
||||||
|
/// The regression this pins down: `PUNKTFUNK_COMPOSITOR=mutter` on a box whose gnome-shell had
|
||||||
|
/// segfaulted. The pin put Mutter in `available()` and suppressed the `XDG_CURRENT_DESKTOP`
|
||||||
|
/// scrub, so every connect "resolved" happily and then spent 8 retries on
|
||||||
|
/// `RemoteDesktop.CreateSession: ServiceUnknown` — while the operator's
|
||||||
|
/// `PUNKTFUNK_RECOVER_SESSION_CMD` sat unreachable behind a `None` arm that could never fire.
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
#[test]
|
||||||
|
fn a_pin_at_a_dead_session_recovers_instead_of_retrying() {
|
||||||
|
use super::pinned_at_a_dead_session as dead;
|
||||||
|
use crate::vdisplay::{ActiveKind, Compositor::*};
|
||||||
|
// The bug: pinned to a desktop backend with nothing live for this uid.
|
||||||
|
assert!(dead(true, Mutter, ActiveKind::None));
|
||||||
|
assert!(dead(true, Kwin, ActiveKind::None));
|
||||||
|
assert!(dead(true, Wlroots, ActiveKind::None));
|
||||||
|
assert!(dead(true, Hyprland, ActiveKind::None));
|
||||||
|
// Pinned but the session IS up — the ordinary case, must not bail.
|
||||||
|
assert!(!dead(true, Mutter, ActiveKind::DesktopGnome));
|
||||||
|
// Gamescope stands its own session up from nothing: pinning it on a headless box is a
|
||||||
|
// SUPPORTED setup, not a dead session. (This is the .21 no-login workaround — never break it.)
|
||||||
|
assert!(!dead(true, Gamescope, ActiveKind::None));
|
||||||
|
// Unpinned is untouched: the auto path already reaches `pick_compositor`'s `None` arm via
|
||||||
|
// `compositor_for_kind(ActiveKind::None)`, and it owns the managed-takeover case.
|
||||||
|
assert!(!dead(false, Mutter, ActiveKind::None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gamescope is the ONLY backend that can serve a connect with no session already running.
|
||||||
|
#[test]
|
||||||
|
fn only_gamescope_survives_a_dead_session() {
|
||||||
|
use crate::vdisplay::Compositor::*;
|
||||||
|
assert!(!Gamescope.needs_live_session());
|
||||||
|
for c in [Mutter, Kwin, Wlroots, Hyprland] {
|
||||||
|
assert!(c.needs_live_session(), "{c:?} needs a live compositor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compositor_resolution_precedence() {
|
fn compositor_resolution_precedence() {
|
||||||
use crate::vdisplay::Compositor::*;
|
use crate::vdisplay::Compositor::*;
|
||||||
|
|||||||
@@ -270,13 +270,15 @@ pub(super) async fn negotiate(
|
|||||||
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
||||||
// (review #9). (dedicated is Linux-only, and only there does `resolve_launch` carry a
|
// (review #9). (dedicated is Linux-only, and only there does `resolve_launch` carry a
|
||||||
// command — on Windows the concrete process is resolved at launch time instead.)
|
// command — on Windows the concrete process is resolved at launch time instead.)
|
||||||
|
// `launch_is_resolvable`, not a full `resolve_launch`: a `plugin`-kind entry's command
|
||||||
|
// is fetched from the owning plugin over loopback, and this runs on the async path. The
|
||||||
|
// cheap check answers the only question asked here (does this tile launch anything?)
|
||||||
|
// without a blocking call — see `library::launch_is_resolvable`.
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let has_resolvable_launch = hello
|
let has_resolvable_launch = hello
|
||||||
.launch
|
.launch
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(crate::library::resolve_launch)
|
.is_some_and(crate::library::launch_is_resolvable);
|
||||||
.and_then(|t| t.command)
|
|
||||||
.is_some();
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let has_resolvable_launch = false;
|
let has_resolvable_launch = false;
|
||||||
let dedicated = crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
|
let dedicated = crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
|
||||||
|
|||||||
@@ -1553,6 +1553,46 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
encoder supports chunked output"
|
encoder supports chunked output"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// A mode switch the control task accepted BEFORE the pipeline was built (the client connects at
|
||||||
|
// one mode and immediately asks for its real one — a fractional-scale panel resolving its native
|
||||||
|
// pixel size does exactly this, ~3 s ahead of bring-up finishing) used to be served the long way
|
||||||
|
// round: build the whole pipeline at the now-stale mode, then immediately rebuild at the new one
|
||||||
|
// in the loop below. That wastes a display create + capture attach + encoder open on every such
|
||||||
|
// connect, and on GNOME it is actively destructive — the rebuild is create-before-drop, so two
|
||||||
|
// `RecordVirtual` monitors ~400 ms apart segfault mutter 50.4 inside
|
||||||
|
// `meta_monitor_manager_rebuild`, taking down the whole desktop session (and with it the game
|
||||||
|
// just launched into it, which then looks like the GAME crashed). Adopt the newest queued mode
|
||||||
|
// here and build ONCE.
|
||||||
|
//
|
||||||
|
// Only on the inline path: a PREPARED pipeline is already built at the old mode, so adopting a
|
||||||
|
// new `mode` there would just make this variable disagree with the display that exists. Those
|
||||||
|
// sessions keep the rebuild-in-the-loop behavior. No accept ack is owed either way — the
|
||||||
|
// client's mode slot already flipped when control accepted the switch (it acks on accept, not
|
||||||
|
// on rebuild); the H2/H3 *correction* ack the rebuild would have sent is preserved below.
|
||||||
|
let mut mode = mode;
|
||||||
|
let mut adopted_at_bringup = false;
|
||||||
|
if prepared.is_none() {
|
||||||
|
let mut queued = None;
|
||||||
|
while let Ok(m) = reconfig.try_recv() {
|
||||||
|
queued = Some(m);
|
||||||
|
}
|
||||||
|
if let Some(m) = queued.filter(|m| *m != mode) {
|
||||||
|
adopted_at_bringup = true;
|
||||||
|
tracing::info!(
|
||||||
|
stale = ?mode,
|
||||||
|
adopted = ?m,
|
||||||
|
"a mode switch was accepted before bring-up finished — building at the new mode \
|
||||||
|
instead of building twice"
|
||||||
|
);
|
||||||
|
mode = m;
|
||||||
|
// Mirror the loop's rebuild: PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin, so
|
||||||
|
// a resolution change moves the operating point. Explicit client rates stay put.
|
||||||
|
if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||||
|
bitrate_kbps =
|
||||||
|
resolve_bitrate_kbps_for(plan.codec, 0, &mode, plan.chroma, plan.bit_depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
compositor = compositor.id(),
|
compositor = compositor.id(),
|
||||||
?mode,
|
?mode,
|
||||||
@@ -1666,6 +1706,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
&live_bitrate,
|
&live_bitrate,
|
||||||
&retarget_tx,
|
&retarget_tx,
|
||||||
);
|
);
|
||||||
|
// H2/H3 correction, carried over from the rebuild this bring-up replaced: the client APPLIED
|
||||||
|
// the mode when control accepted it, but the backend may have honored a different one (KWin
|
||||||
|
// caps a virtual output's refresh; a fallback delivers the size the source actually produces).
|
||||||
|
// Only for a mode adopted at bring-up — an ordinary connect's mode came from the Welcome, not
|
||||||
|
// from an accept the client has already acted on, so it is not owed a correction here.
|
||||||
|
if adopted_at_bringup {
|
||||||
|
let actual = delivered_mode(frame.width, frame.height, interval);
|
||||||
|
if actual != mode {
|
||||||
|
let _ = reconfig_result_tx.send(Reconfigured {
|
||||||
|
accepted: true,
|
||||||
|
mode: actual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Capture is live — launch the requested title so it renders onto the streamed output and
|
// Capture is live — launch the requested title so it renders onto the streamed output and
|
||||||
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ sudo pacman -Syu punktfunk-scripting # optional: the plugin/script runner (see b
|
|||||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games
|
||||||
|
as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply)
|
||||||
|
```
|
||||||
|
|
||||||
|
That is a second group on purpose. It grants write access to the usbip `attach` file, which
|
||||||
|
materialises an arbitrary emulated USB device — so it stays off the `input` group everyone is
|
||||||
|
routinely told to join. Join it only on a machine you trust. Without it, everything else still
|
||||||
|
works and the pad simply arrives as an ordinary Xbox 360 controller.
|
||||||
|
|
||||||
Each install is a **full** `-Syu`, on purpose: our packages are built against current Arch
|
Each install is a **full** `-Syu`, on purpose: our packages are built against current Arch
|
||||||
sonames, and `pacman -Sy <pkg>` would drop one onto a system whose other packages are still old —
|
sonames, and `pacman -Sy <pkg>` would drop one onto a system whose other packages are still old —
|
||||||
the classic partial upgrade that breaks Arch boxes. To take several in one go, name them on a
|
the classic partial upgrade that breaks Arch boxes. To take several in one go, name them on a
|
||||||
|
|||||||
@@ -126,6 +126,18 @@ ujust add-user-to-input-group
|
|||||||
Then **log out and back in**. (A controller that's "detected but does nothing" is almost always this
|
Then **log out and back in**. (A controller that's "detected but does nothing" is almost always this
|
||||||
permission, not a client problem.)
|
permission, not a client problem.)
|
||||||
|
|
||||||
|
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
|
||||||
|
`punktfunk` — `usermod` is fine here, because unlike `input` this group is ours and the sysext
|
||||||
|
creates it on merge:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo usermod -aG punktfunk "$USER" # then log out and back in
|
||||||
|
```
|
||||||
|
|
||||||
|
It is a separate group on purpose: it gates the usbip `attach` file, which can materialise
|
||||||
|
arbitrary emulated USB hardware, so it is not folded into the group everyone is told to join for
|
||||||
|
gamepads. Skip it and the pad arrives as an ordinary Xbox 360 controller instead.
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
The RPM ships a Bazzite-tuned config you can copy as your starting point:
|
The RPM ships a Bazzite-tuned config you can copy as your starting point:
|
||||||
|
|||||||
@@ -96,11 +96,12 @@ those hiccups out, at that buffer's worth of added delay. Linux and Windows apps
|
|||||||
home; the Apple and Android apps have carried the same setting for a while, and it is stored
|
home; the Apple and Android apps have carried the same setting for a while, and it is stored
|
||||||
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device.
|
||||||
|
|
||||||
**Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How
|
**Smoothness buffer** — *default: Automatic (two frames).* How many frames are held back before
|
||||||
many frames are held back before showing. Each frame absorbs roughly one screen refresh of network
|
showing. Each frame absorbs roughly one screen refresh of network hiccup and costs one refresh of
|
||||||
hiccup and costs one refresh of delay — so on a 120 Hz screen, two frames is about 17 ms of extra
|
delay — so on a 120 Hz screen, two frames is about 17 ms of extra delay bought against 17 ms of
|
||||||
delay bought against 17 ms of jitter. If you never see stutter, you don't need this. Wherever
|
jitter. If you never see stutter, you don't need this. The row appears wherever **Prioritize** is
|
||||||
**Prioritize** is offered, and greyed out until you pick Smoothness.
|
offered, and only once you have picked **Smoothness** — under Lowest latency there are no held
|
||||||
|
frames for it to count, so it isn't shown at all.
|
||||||
|
|
||||||
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
|
**V-Sync** — *default: on.* Tear-free presentation. Turning it off asks the GPU to show each frame
|
||||||
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
|
the instant it's ready instead of waiting for the screen's next refresh: the lowest delay a display
|
||||||
@@ -263,6 +264,43 @@ when you return to the host list. The console home carries the row for the deskt
|
|||||||
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV
|
||||||
and Android have no equivalent.
|
and Android have no equivalent.
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
These change how the client itself looks and behaves. None of them touches a stream, so none of them
|
||||||
|
can live in a [profile](/docs/profiles-and-links) — they are decisions about the device in front of
|
||||||
|
you.
|
||||||
|
|
||||||
|
**Gamepad-optimized browsing** — *default: on.* Swaps the touch or desktop home for the
|
||||||
|
controller-optimized one: the host carousel, larger focus targets, a swipeable cover browser, and
|
||||||
|
settings you can step with a thumbstick. The Apple and Android apps have this switch. Turn it off to
|
||||||
|
stay in the touch interface even with a pad in your hands. On Linux, Windows and the Steam Deck the
|
||||||
|
controller-optimized home is a separate entry point rather than a switch, so there is nothing to
|
||||||
|
turn off. An Android TV is always in this mode — its remote is the only input it has.
|
||||||
|
|
||||||
|
**Show it** — *default: With a controller.* Only shown while the switch above is on, and it decides
|
||||||
|
*when* that switch takes effect. **With a controller** is the long-standing behaviour: the
|
||||||
|
controller-optimized home appears as a pad connects and the touch interface returns when the last one
|
||||||
|
disconnects. **Always** keeps the controller-optimized home either way — for a phone or tablet that
|
||||||
|
lives docked to a TV, where the pad isn't always awake but the couch layout is always the one you
|
||||||
|
want. Apple and Android. (An Android TV is in that mode regardless, so the choice changes nothing
|
||||||
|
there.)
|
||||||
|
|
||||||
|
**Background** — *default: Violet.* The colour family the controller-optimized home's living backdrop
|
||||||
|
drifts through. Thirteen of them: seven dark fields — **Violet**, **OLED**, **Nebula**, **Abyss**,
|
||||||
|
**Ember**, **Moss**, **Graphite** — then six pale ones, **Holo**, **Sunset**, **Bloom**, **Dawn**,
|
||||||
|
**Mint** and **Opal**, which flip the whole interface to dark text on a light field. The backdrop
|
||||||
|
recolours as you step the row, so pick by looking. **OLED** is the one with a practical point rather
|
||||||
|
than a decorative one: it is true black — most of the frame is pixels switched off, which on an OLED
|
||||||
|
or AMOLED panel means no glow and no power drawn, with only a faint violet ember left in one corner.
|
||||||
|
Stored under the same name on every client, so a phone, a Deck and a desktop set to Mint all look
|
||||||
|
alike. Appearance only — nothing about a stream depends on it.
|
||||||
|
|
||||||
|
The row lives in the controller-optimized settings themselves — the screen you reach with **X** from
|
||||||
|
the controller-optimized home — on every platform that has one, which includes the Steam Deck and the
|
||||||
|
Linux and Windows console home. The Apple TV is the exception: it carries **Background** in its
|
||||||
|
ordinary Settings instead, next to **Show it**, because its controller-optimized home needs a real
|
||||||
|
controller to open and the palettes would otherwise be unreachable from the Siri Remote.
|
||||||
|
|
||||||
## Overlay
|
## Overlay
|
||||||
|
|
||||||
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
**Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a
|
||||||
@@ -292,6 +330,10 @@ stay global and **cannot be put in a settings profile**:
|
|||||||
profile forwards.
|
profile forwards.
|
||||||
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
|
- **Auto-wake on connect** and **Show game library** — decisions about this device and this network,
|
||||||
not about how a given host is streamed.
|
not about how a given host is streamed.
|
||||||
|
- Everything under **Interface** — **Gamepad-optimized browsing**, **Show it** and **Background**.
|
||||||
|
How this client looks and which layout it wears has nothing to do with how a host streams to it,
|
||||||
|
so binding them to a host would only make the same device change appearance depending on what it
|
||||||
|
connected to.
|
||||||
|
|
||||||
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
|
One switch you might expect here isn't in Settings at all: **Share clipboard** lives in a saved
|
||||||
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —
|
host's own edit sheet, because handing a machine your clipboard is a decision about that one host —
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
| `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. |
|
||||||
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
||||||
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length in frames (unset = about ten minutes' worth, `fps × 600`; set `0` for encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
||||||
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. |
|
| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. ⚠️ On gamescope that one number is also the refresh the session **reports**: Steam's in-session display settings and every game will read the display as `N` Hz, and a game that paces itself to the display will hold itself there. If you want a quieter box without games believing the panel changed, cap the client's requested refresh instead. |
|
||||||
|
| `PUNKTFUNK_GAMESCOPE_REFRESH_RATES` | e.g. `60,90,120` *(default: just the session's own rate)* | Extra refresh rates a gamescope session **offers** in its in-session display settings. A headless gamescope has no EDID, so it cannot work out what else the display could run at — without this it advertises exactly one rate and Steam's refresh menu has a single entry. The rate the session actually runs at is always included, so this can only add options. Needs the `punktfunk-gamescope` build (`+pfhdr3`); ignored on a stock gamescope, which has no flag to take it. |
|
||||||
| `PUNKTFUNK_VDISPLAY_HZ_MULT` | `1`–`4` *(default `1` = off)* | Run the **virtual display** at a multiple of the session's frame rate without sending a single extra frame. A compositor paints on its own vblank, so a frame finished just after the capture sampled waits nearly a whole interval to be picked up — the jittery part of the latency budget. At `2` that worst case halves. Costs the compositor and GPU the extra composites, so it's opt-in. If the backend won't give the multiplied rate it reports what it achieved and the stream paces to that. |
|
| `PUNKTFUNK_VDISPLAY_HZ_MULT` | `1`–`4` *(default `1` = off)* | Run the **virtual display** at a multiple of the session's frame rate without sending a single extra frame. A compositor paints on its own vblank, so a frame finished just after the capture sampled waits nearly a whole interval to be picked up — the jittery part of the latency budget. At `2` that worst case halves. Costs the compositor and GPU the extra composites, so it's opt-in. If the backend won't give the multiplied rate it reports what it achieved and the stream paces to that. |
|
||||||
|
|
||||||
## Gamepads
|
## Gamepads
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ sudo dnf install punktfunk
|
|||||||
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games
|
||||||
|
as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply)
|
||||||
|
```
|
||||||
|
|
||||||
|
That is a second group on purpose: it grants write access to the usbip `attach` file, which
|
||||||
|
materialises an arbitrary emulated USB device, so it stays off the `input` group everyone is
|
||||||
|
routinely told to join. Join it only on a machine you trust. Skip it and the pad simply arrives as
|
||||||
|
an ordinary Xbox 360 controller.
|
||||||
|
|
||||||
Updates later are just `sudo dnf upgrade punktfunk`, followed by
|
Updates later are just `sudo dnf upgrade punktfunk`, followed by
|
||||||
`systemctl --user restart punktfunk-host` so the running host picks up the new binary. The package
|
`systemctl --user restart punktfunk-host` so the running host picks up the new binary. The package
|
||||||
ships the systemd user units, the udev rule, the UDP socket-buffer sysctl tuning, and example
|
ships the systemd user units, the udev rule, the UDP socket-buffer sysctl tuning, and example
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ To stream real HDR you need `punktfunk-gamescope`: gamescope plus a small patch
|
|||||||
own name and does **not** replace your system gamescope — your Gaming Mode keeps using that one.
|
own name and does **not** replace your system gamescope — your Gaming Mode keeps using that one.
|
||||||
|
|
||||||
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
|
- **Bazzite / Fedora Atomic** — included in the Punktfunk sysext; `punktfunk-sysext update` gets it.
|
||||||
|
- **Fedora, Nobara and other RPM boxes** — `sudo dnf install punktfunk-gamescope` from the same
|
||||||
|
Punktfunk repo the host comes from.
|
||||||
|
- **Debian / Ubuntu** — `sudo apt install punktfunk-gamescope` from the Punktfunk apt repo.
|
||||||
- **Arch** — the `punktfunk-gamescope` package.
|
- **Arch** — the `punktfunk-gamescope` package.
|
||||||
- **SteamOS (Steam Deck installer)** — built and wired automatically by
|
- **SteamOS (Steam Deck installer)** — built and wired automatically by
|
||||||
`scripts/steamdeck/install.sh` / `update.sh`.
|
`scripts/steamdeck/install.sh` / `update.sh`.
|
||||||
@@ -200,6 +203,22 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un
|
|||||||
capture node, so the overlay is missing from an otherwise perfect picture. Either case is logged
|
capture node, so the overlay is missing from an otherwise perfect picture. Either case is logged
|
||||||
at startup with the version found. Bazzite's and SteamOS's current gamescope is past both; this
|
at startup with the version found. Bazzite's and SteamOS's current gamescope is past both; this
|
||||||
only bites if you've pinned an old one.
|
only bites if you've pinned an old one.
|
||||||
|
- **On a stock gamescope, Gaming Mode reports the wrong refresh rate — and offers no resolutions.**
|
||||||
|
A headless gamescope has no EDID, and upstream's headless connector advertises no display modes
|
||||||
|
and no refresh rates at all. Steam's in-session display settings then show a single refresh entry
|
||||||
|
and an empty resolution list, and that one entry is whatever the session was launched with — or
|
||||||
|
**60 Hz** if the launch flag went missing. Games that pace themselves to the display will hold
|
||||||
|
themselves there, even though the stream is running at your client's full rate (the client's own
|
||||||
|
fps counter keeps reading correctly, because the encoder repeats held frames — so the counter is
|
||||||
|
not the thing to trust here; an in-game fps readout is). `punktfunk-gamescope` publishes the real
|
||||||
|
mode and rate, and `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` puts more than one entry in that
|
||||||
|
menu. If the host log says *"the session did not start at the mode we asked for"*, a file in
|
||||||
|
`/etc/gamescope-session-plus/sessions.d/` is overriding `GAMESCOPE_BIN` or setting `GAMESCOPECMD`.
|
||||||
|
- **The performance overlay (fps / frametime / stats) needs the patched build.** It is mangoapp,
|
||||||
|
which gamescope draws as an *external overlay* — a layer upstream's capture composite has never
|
||||||
|
included on any version, so on a stock gamescope you can turn the overlay on and it simply will
|
||||||
|
not appear in the stream. There is no host-side substitute: the host cannot reconstruct another
|
||||||
|
process's overlay window. `punktfunk-gamescope` paints it into the capture stream.
|
||||||
- **The cursor comes from the compositor when it can, and from the host otherwise.** A stock
|
- **The cursor comes from the compositor when it can, and from the host otherwise.** A stock
|
||||||
gamescope leaves the pointer out of its captured image, so the host reads it separately and draws
|
gamescope leaves the pointer out of its captured image, so the host reads it separately and draws
|
||||||
it into every frame — a full pass over the picture, and the fastest encode source cannot blend at
|
it into every frame — a full pass over the picture, and the fastest encode source cannot blend at
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ to one readable line.
|
|||||||
- **Apple TV** has no keyboard path, and a short press of the Siri Remote's Back button deliberately
|
- **Apple TV** has no keyboard path, and a short press of the Siri Remote's Back button deliberately
|
||||||
does nothing — so a controller's B button can't end your session by accident. To leave, **hold
|
does nothing — so a controller's B button can't end your session by accident. To leave, **hold
|
||||||
Back for about a second and let go**. During a session the remote's touch surface drives the host
|
Back for about a second and let go**. During a session the remote's touch surface drives the host
|
||||||
cursor, a press is a left click, and Play/Pause is a right click.
|
cursor, a press is a left click, and Play/Pause is a right click — **hold Play/Pause** instead and
|
||||||
|
it cycles the [stats overlay](/docs/stats). With a controller in hand, **Select + X** does the
|
||||||
|
same on every Apple client.
|
||||||
|
|
||||||
### Leaving with a controller
|
### Leaving with a controller
|
||||||
|
|
||||||
@@ -99,6 +101,19 @@ there the client stops opening the controller at all, which is the point of the
|
|||||||
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
|
**Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep
|
||||||
watching for the chord either way.
|
watching for the chord either way.
|
||||||
|
|
||||||
|
### Statistics with a controller
|
||||||
|
|
||||||
|
The **Apple** apps reserve a second chord: **Select + X**, which cycles the
|
||||||
|
[stats overlay](/docs/stats) one level each time you complete it. It is for the moment your hands
|
||||||
|
are on a controller and the usual routes aren't — no keyboard for **⌃⌥⇧S**, no free screen for the
|
||||||
|
three-finger tap — and on **Apple TV** it is the only way there with a pad. X is deliberately none
|
||||||
|
of the four leave-chord buttons, so reaching for one chord never trips the other. Both buttons
|
||||||
|
still reach the game; only the overlay changes locally.
|
||||||
|
|
||||||
|
On the **Siri Remote**, **hold Play/Pause** for about half a second instead. A quick tap of that
|
||||||
|
button is still a right click — the click is simply sent when you let go, so the hold has
|
||||||
|
something to be.
|
||||||
|
|
||||||
### The guide button (Xbox / PS / Steam) and Quick Access
|
### The guide button (Xbox / PS / Steam) and Quick Access
|
||||||
|
|
||||||
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
|
A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ you; on NixOS the module does steps 1 and 2, and [NixOS](#nixos) above has the u
|
|||||||
input](/docs/input#pen-and-stylus) both need `/dev/uinput` — then re-login. The exact
|
input](/docs/input#pen-and-stylus) both need `/dev/uinput` — then re-login. The exact
|
||||||
command differs per distro — see your guide (`usermod -aG input "$USER"`, or `ujust
|
command differs per distro — see your guide (`usermod -aG input "$USER"`, or `ujust
|
||||||
add-user-to-input-group` on Bazzite).
|
add-user-to-input-group` on Bazzite).
|
||||||
|
|
||||||
|
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
|
||||||
|
`punktfunk`: `sudo usermod -aG punktfunk "$USER"`. Your package created that group at install
|
||||||
|
time; it gates the usbip nodes that pad attaches through, and it is separate from `input` on
|
||||||
|
purpose, because writing them can present arbitrary emulated USB hardware. Join it only on a
|
||||||
|
machine you trust — skipping it costs you nothing but that one pad type.
|
||||||
2. Put your `host.env` in place, then start the host. Every Linux package ships a systemd **user**
|
2. Put your `host.env` in place, then start the host. Every Linux package ships a systemd **user**
|
||||||
unit, so you don't run the host by hand — but that unit reads `~/.config/punktfunk/host.env` and
|
unit, so you don't run the host by hand — but that unit reads `~/.config/punktfunk/host.env` and
|
||||||
won't start until the file exists. Each package ships a template to copy; your distro and desktop
|
won't start until the file exists. Each package ships a template to copy; your distro and desktop
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ in-stream:
|
|||||||
| Linux · Windows · Steam Deck | **Ctrl+Alt+Shift+S** |
|
| Linux · Windows · Steam Deck | **Ctrl+Alt+Shift+S** |
|
||||||
| macOS / iPad (pointer or trackpad) | **⌃⌥⇧S** or a **three-finger tap** |
|
| macOS / iPad (pointer or trackpad) | **⌃⌥⇧S** or a **three-finger tap** |
|
||||||
| Android · iPhone | a **three-finger tap** |
|
| Android · iPhone | a **three-finger tap** |
|
||||||
|
| Apple TV | **hold Play/Pause** on the Siri Remote |
|
||||||
|
| Any Apple client, controller in hand | **Select + X** |
|
||||||
|
|
||||||
|
**Select + X** is there for the times your hands are on a controller and the other routes aren't:
|
||||||
|
no keyboard for the combo, no free screen for the tap. On an **Apple TV** it is the only one of
|
||||||
|
the two you can reach with a game controller, and holding **Play/Pause** is the equivalent on the
|
||||||
|
Siri Remote — a *tap* on that button still right-clicks, only the hold cycles the overlay.
|
||||||
|
|
||||||
**Ctrl+Alt+Shift+S** is one of a small set of shortcuts a stream reserves; the others — release
|
**Ctrl+Alt+Shift+S** is one of a small set of shortcuts a stream reserves; the others — release
|
||||||
captured input, switch mouse mode, disconnect, mute the microphone — are in
|
captured input, switch mouse mode, disconnect, mute the microphone — are in
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ It is idempotent — safe to re-run. In one pass it:
|
|||||||
[plugin store](/docs/plugins) works out of the box — the runner service itself stays opt-in),
|
[plugin store](/docs/plugins) works out of the box — the runner service itself stays opt-in),
|
||||||
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
3. writes config to `~/.config/punktfunk/` (a generated web-console login password),
|
||||||
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
4. raises the UDP socket buffers to 32 MB, installs the gamepad udev rule + the `vhci-hcd` autoload
|
||||||
and adds you to the `input` group (virtual gamepads / **native Steam Deck controller passthrough**),
|
and adds you to the `input` group (virtual gamepads) **and the `punktfunk` group** (the usbip
|
||||||
seeds the KDE RemoteDesktop grant for Desktop-mode input, and **registers all of it on SteamOS's
|
nodes **native Steam Deck controller passthrough** attaches through — creating that group if it
|
||||||
|
does not exist yet), seeds the KDE RemoteDesktop grant for Desktop-mode input, and **registers all of it on SteamOS's
|
||||||
atomic-update keep list** so OS updates carry it over — the installer asks for your `sudo`
|
atomic-update keep list** so OS updates carry it over — the installer asks for your `sudo`
|
||||||
password **first, before the long build**, so you can authorise once and walk away,
|
password **first, before the long build**, so you can authorise once and walk away,
|
||||||
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
5. installs + starts the `punktfunk-host` and `punktfunk-web` **systemd user services** (with linger,
|
||||||
@@ -102,7 +103,8 @@ When it finishes it prints the web-console URL and how to pair.
|
|||||||
> surface at all.
|
> surface at all.
|
||||||
|
|
||||||
> **First install — reboot once before streaming.** KWin only authorizes Desktop-mode screen capture
|
> **First install — reboot once before streaming.** KWin only authorizes Desktop-mode screen capture
|
||||||
> on a fresh session, and the new `input` group (native Steam Deck controller passthrough) only takes
|
> on a fresh session, and the new `input` and `punktfunk` groups (native Steam Deck controller
|
||||||
|
> passthrough) only take
|
||||||
> effect on a new login — so after the **first** install, **reboot the Deck** (a re-run that changes
|
> effect on a new login — so after the **first** install, **reboot the Deck** (a re-run that changes
|
||||||
> nothing doesn't need it). Streaming **Game Mode** with a generic Xbox pad works right away; **Desktop
|
> nothing doesn't need it). Streaming **Game Mode** with a generic Xbox pad works right away; **Desktop
|
||||||
> capture and the native Steam Deck controller need the reboot.** If a client connects and every
|
> capture and the native Steam Deck controller need the reboot.** If a client connects and every
|
||||||
@@ -127,6 +129,13 @@ The installer generates a random console login password (printed at the end of s
|
|||||||
to `~/.config/punktfunk/web.env`. To read it back or set your own, see
|
to `~/.config/punktfunk/web.env`. To read it back or set your own, see
|
||||||
[The Web Console](/docs/web-console#login-password).
|
[The Web Console](/docs/web-console#login-password).
|
||||||
|
|
||||||
|
> **Installed before 0.25.0? Rotate that password once.** Older versions of the script created
|
||||||
|
> `web.env` at the account's default umask, so the console password and session secret sat on disk
|
||||||
|
> world-readable — any local account could read them. Re-running `install.sh` or `update.sh` now
|
||||||
|
> tightens the file to `0600` and tells you it did, but a chmod cannot un-leak a secret that was
|
||||||
|
> already readable. Change `PUNKTFUNK_UI_PASSWORD` in `~/.config/punktfunk/web.env`, then
|
||||||
|
> `systemctl --user restart punktfunk-web`.
|
||||||
|
|
||||||
## 4. Verify
|
## 4. Verify
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -177,8 +186,11 @@ bash ~/punktfunk/scripts/steamdeck/update.sh --pull
|
|||||||
```
|
```
|
||||||
|
|
||||||
Drop `--pull` if you rsync source in yourself. `update.sh` also retrofits anything a newer installer
|
Drop `--pull` if you rsync source in yourself. `update.sh` also retrofits anything a newer installer
|
||||||
adds — the plugin runner, the HDR gamescope, the atomic-update keep list, the rebuild check — onto an
|
adds — the plugin runner, the HDR gamescope, the atomic-update keep list, the rebuild check, the
|
||||||
older install.
|
`punktfunk` group, and the `0600`/`0700` permissions on `~/.config/punktfunk` — onto an older
|
||||||
|
install. You do **not** need to run the group or firewall commands from the release notes by hand on
|
||||||
|
a Deck: the group is the script's job, and stock SteamOS runs no firewall for the port note to apply
|
||||||
|
to. Rotating the console password after a pre-0.25.0 install is the one thing still on you (above).
|
||||||
|
|
||||||
> **This install follows the canary channel.** An on-device source build tracks `main`, not stable
|
> **This install follows the canary channel.** An on-device source build tracks `main`, not stable
|
||||||
> `vX.Y.Z` releases, so the console offers you the newest `main` build. See
|
> `vX.Y.Z` releases, so the console offers you the newest `main` build. See
|
||||||
@@ -247,7 +259,9 @@ rm -rf ~/.config/punktfunk
|
|||||||
And the installer may have seeded a KDE RemoteDesktop portal grant at
|
And the installer may have seeded a KDE RemoteDesktop portal grant at
|
||||||
`~/.local/share/flatpak/db/kde-authorized` (only if you had none); remove that file if nothing
|
`~/.local/share/flatpak/db/kde-authorized` (only if you had none); remove that file if nothing
|
||||||
else on the device relies on it. Your `input` group membership is harmless to keep — drop it with
|
else on the device relies on it. Your `input` group membership is harmless to keep — drop it with
|
||||||
`sudo gpasswd -d "$USER" input` if you'd rather not.
|
`sudo gpasswd -d "$USER" input` if you'd rather not. The `punktfunk` group is worth actually
|
||||||
|
dropping once the host is gone, because it can present emulated USB hardware and nothing else uses
|
||||||
|
it: `sudo gpasswd -d "$USER" punktfunk`.
|
||||||
|
|
||||||
See [Uninstalling](/docs/uninstall) for the other install methods and what each one leaves behind.
|
See [Uninstalling](/docs/uninstall) for the other install methods and what each one leaves behind.
|
||||||
|
|
||||||
@@ -258,9 +272,14 @@ See [Uninstalling](/docs/uninstall) for the other install methods and what each
|
|||||||
- **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off
|
- **Keep the device awake.** On handhelds, Game Mode auto-suspends on idle, which drops the host off
|
||||||
the network mid stream — disable auto-suspend (Settings → Power) for a headless host.
|
the network mid stream — disable auto-suspend (Settings → Power) for a headless host.
|
||||||
- **Native Steam Deck controller passthrough** presents the client's pad as a real Steam Deck
|
- **Native Steam Deck controller passthrough** presents the client's pad as a real Steam Deck
|
||||||
controller (paddles, trackpads, gyro) via a virtual USB device — that needs the `input` group and the
|
controller (paddles, trackpads, gyro) via a virtual USB device — that needs the `input` **and
|
||||||
|
`punktfunk`** groups and the
|
||||||
`vhci-hcd` module live, so it only works **after the first-install reboot** above; until then the pad
|
`vhci-hcd` module live, so it only works **after the first-install reboot** above; until then the pad
|
||||||
degrades to a generic Xbox 360 controller (still fully playable). If you're streaming *to* another
|
degrades to a generic Xbox 360 controller (still fully playable). The second group is separate on
|
||||||
|
purpose: it can present arbitrary emulated USB hardware, which is why it is not folded into the
|
||||||
|
`input` group every gamepad guide tells you to join. Check both with `id -nG`, and check the nodes
|
||||||
|
themselves with `ls -l /sys/devices/platform/vhci_hcd.0/attach` — group `punktfunk`, mode `0660`.
|
||||||
|
If you're streaming *to* another
|
||||||
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
Steam Deck, also set Steam Input to **Off** for Punktfunk on that Deck — see
|
||||||
[Stream to a Steam Deck](/docs/steam-deck).
|
[Stream to a Steam Deck](/docs/steam-deck).
|
||||||
- **It survives OS updates — automatically.** SteamOS A/B updates rebuild `/etc` and can move
|
- **It survives OS updates — automatically.** SteamOS A/B updates rebuild `/etc` and can move
|
||||||
|
|||||||
@@ -250,6 +250,31 @@ switch mouse mode, disconnect, fullscreen — are in
|
|||||||
button (see [Updating](/docs/updating)). Swapping `punktfunk-host.exe` by hand does not fix it,
|
button (see [Updating](/docs/updating)). Swapping `punktfunk-host.exe` by hand does not fix it,
|
||||||
because the stale controller device keeps the driver it was already bound to.
|
because the stale controller device keeps the driver it was already bound to.
|
||||||
|
|
||||||
|
## The pad works, but arrives as an Xbox 360 controller instead of a Steam Deck
|
||||||
|
|
||||||
|
Only the **virtual Steam Deck controller** (paddles, trackpads, gyro) is missing here — ordinary
|
||||||
|
gamepad input is fine. That pad reaches games as a real USB device over usbip, and the sysfs files
|
||||||
|
it attaches through are owned by a group called `punktfunk`, separate from `input`. Four things
|
||||||
|
have to line up on the Linux host, and none of them announces itself when it doesn't:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
getent group punktfunk # the group exists at all
|
||||||
|
id -nG | tr ' ' '\n' | grep -x punktfunk # ...and you are in it
|
||||||
|
ls -l /sys/devices/platform/vhci_hcd.0/attach # owned by punktfunk, mode 0660
|
||||||
|
lsmod | grep vhci_hcd # the transport module is loaded
|
||||||
|
```
|
||||||
|
|
||||||
|
If the group is missing entirely, the udev rule tried to `chgrp` to a group nobody created, so the
|
||||||
|
nodes stayed root-only. That was the case on installs that reached 0.25.0 by **upgrade** on Arch,
|
||||||
|
on NixOS, on the Bazzite sysext, and on Steam Deck source installs. Re-running your package
|
||||||
|
manager's upgrade (or `update.sh` on a Deck) creates it now; otherwise `sudo groupadd --system
|
||||||
|
punktfunk` by hand. Then `sudo usermod -aG punktfunk "$USER"` and **log out and back in** — group
|
||||||
|
changes only reach the host's `systemd --user` service on a fresh login, and on a Deck a reboot is
|
||||||
|
the reliable way to get one.
|
||||||
|
|
||||||
|
Joining the group is optional, and there is a real reason it is not automatic: writing that
|
||||||
|
`attach` file materialises an arbitrary emulated USB device. Skip it on a machine you share.
|
||||||
|
|
||||||
## Copy and paste between host and client does nothing
|
## Copy and paste between host and client does nothing
|
||||||
|
|
||||||
The shared clipboard needs **two** separate switches on, and turning on only one looks exactly like
|
The shared clipboard needs **two** separate switches on, and turning on only one looks exactly like
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ re-login so the new group membership takes effect:
|
|||||||
sudo usermod -aG input "$USER" # re-login to apply
|
sudo usermod -aG input "$USER" # re-login to apply
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join
|
||||||
|
`punktfunk`. That pad reaches games as a real USB device over usbip — which is what makes Steam
|
||||||
|
Input adopt it — and the group gating those nodes is deliberately separate from `input`, because
|
||||||
|
writing the usbip `attach` file can materialise arbitrary emulated USB hardware:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo usermod -aG punktfunk "$USER" # re-login to apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Join it only on a machine you trust. Skip it and everything else still works; the pad just arrives
|
||||||
|
as an ordinary Xbox 360 controller.
|
||||||
|
|
||||||
## 4. Check it installed
|
## 4. Check it installed
|
||||||
|
|
||||||
Before moving on, confirm the binary is there and nothing else is competing for the same job:
|
Before moving on, confirm the binary is there and nothing else is competing for the same job:
|
||||||
|
|||||||
@@ -58,16 +58,20 @@ sudo rm -f /etc/apt/sources.list.d/punktfunk.list /etc/apt/keyrings/punktfunk.as
|
|||||||
sudo apt update
|
sudo apt update
|
||||||
```
|
```
|
||||||
|
|
||||||
**Left behind:** `~/.config/punktfunk`, and the empty `punktfunk-update` system group the package
|
**Left behind:** `~/.config/punktfunk`, and the two system groups the package created — the empty
|
||||||
created for [one-click updates](/docs/updating). Clear them with:
|
`punktfunk-update` for [one-click updates](/docs/updating), and `punktfunk` for the virtual Steam
|
||||||
|
Deck pad's usbip nodes. Clear them with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rm -rf ~/.config/punktfunk
|
rm -rf ~/.config/punktfunk
|
||||||
sudo groupdel punktfunk-update
|
sudo groupdel punktfunk-update
|
||||||
|
sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk
|
||||||
```
|
```
|
||||||
|
|
||||||
Your `input` group membership is harmless to keep (it is a stock Ubuntu group). Drop it with
|
Your `input` group membership is harmless to keep (it is a stock Ubuntu group). Drop it with
|
||||||
`sudo gpasswd -d "$USER" input` if you'd rather not have it. If you opened the firewall, close it
|
`sudo gpasswd -d "$USER" input` if you'd rather not have it. The `punktfunk` group above is worth
|
||||||
|
dropping rather than keeping: it can present arbitrary emulated USB hardware, and with the host
|
||||||
|
gone nothing uses it. If you opened the firewall, close it
|
||||||
again: `sudo ufw delete allow punktfunk-native` (and `punktfunk-gamestream` / `punktfunk-web` if you
|
again: `sudo ufw delete allow punktfunk-native` (and `punktfunk-gamestream` / `punktfunk-web` if you
|
||||||
allowed those too).
|
allowed those too).
|
||||||
|
|
||||||
@@ -80,9 +84,12 @@ sudo dnf remove punktfunk punktfunk-web punktfunk-client punktfunk-scripting
|
|||||||
sudo rm -f /etc/yum.repos.d/punktfunk.repo
|
sudo rm -f /etc/yum.repos.d/punktfunk.repo
|
||||||
```
|
```
|
||||||
|
|
||||||
**Left behind:** `~/.config/punktfunk`, the `punktfunk-update` group, and the signing key dnf
|
**Left behind:** `~/.config/punktfunk`, the `punktfunk-update` and `punktfunk` groups, and the
|
||||||
|
signing key dnf
|
||||||
imported into the rpm keyring when it first installed a Punktfunk package. Clear the first two with
|
imported into the rpm keyring when it first installed a Punktfunk package. Clear the first two with
|
||||||
`rm -rf ~/.config/punktfunk` and `sudo groupdel punktfunk-update`. The key is harmless to leave — on
|
`rm -rf ~/.config/punktfunk` and `sudo groupdel punktfunk-update`; drop `punktfunk` too
|
||||||
|
(`sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk`) — it can present arbitrary
|
||||||
|
emulated USB hardware and nothing uses it once the host is gone. The key is harmless to leave — on
|
||||||
its own it only marks packages from our registry as trusted, and nothing fetches them once the repo
|
its own it only marks packages from our registry as trusted, and nothing fetches them once the repo
|
||||||
file is gone.
|
file is gone.
|
||||||
|
|
||||||
@@ -127,6 +134,7 @@ Three things it created outside `/usr` stay behind:
|
|||||||
```sh
|
```sh
|
||||||
sudo rm -f /etc/modules-load.d/punktfunk.conf /etc/udev/rules.d/60-punktfunk.rules
|
sudo rm -f /etc/modules-load.d/punktfunk.conf /etc/udev/rules.d/60-punktfunk.rules
|
||||||
sudo groupdel punktfunk-update
|
sudo groupdel punktfunk-update
|
||||||
|
sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk
|
||||||
```
|
```
|
||||||
|
|
||||||
And your config, if you want it gone: `rm -rf ~/.config/punktfunk`. See
|
And your config, if you want it gone: `rm -rf ~/.config/punktfunk`. See
|
||||||
@@ -150,8 +158,10 @@ repo's signing key from pacman's keyring:
|
|||||||
sudo pacman-key --delete E0CA04465C99C936E0B0C6510A317015A34DDD69
|
sudo pacman-key --delete E0CA04465C99C936E0B0C6510A317015A34DDD69
|
||||||
```
|
```
|
||||||
|
|
||||||
**Left behind:** `~/.config/punktfunk` and the `punktfunk-update` group —
|
**Left behind:** `~/.config/punktfunk` and the `punktfunk-update` and `punktfunk` groups —
|
||||||
`rm -rf ~/.config/punktfunk` and `sudo groupdel punktfunk-update` clear them. On CachyOS, close the
|
`rm -rf ~/.config/punktfunk`, `sudo groupdel punktfunk-update`, and
|
||||||
|
`sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk` clear them. Drop that last one
|
||||||
|
rather than keeping it: it can present arbitrary emulated USB hardware. On CachyOS, close the
|
||||||
ufw rules you opened: `sudo ufw delete allow punktfunk-native`.
|
ufw rules you opened: `sudo ufw delete allow punktfunk-native`.
|
||||||
|
|
||||||
### SteamOS / Steam Deck host (on-device build)
|
### SteamOS / Steam Deck host (on-device build)
|
||||||
@@ -172,8 +182,10 @@ atomic-update keep list is what carries those files through every SteamOS update
|
|||||||
stay on the device indefinitely.
|
stay on the device indefinitely.
|
||||||
|
|
||||||
**Left behind:** `~/.config/punktfunk` (`rm -rf ~/.config/punktfunk` for a clean slate), your
|
**Left behind:** `~/.config/punktfunk` (`rm -rf ~/.config/punktfunk` for a clean slate), your
|
||||||
`input` group membership, and — if the installer seeded it because you had none — the KDE
|
`input` and `punktfunk` group memberships, and — if the installer seeded it because you had none —
|
||||||
RemoteDesktop portal grant at `~/.local/share/flatpak/db/kde-authorized`.
|
the KDE RemoteDesktop portal grant at `~/.local/share/flatpak/db/kde-authorized`. Drop the second
|
||||||
|
group once the host is gone — it can present arbitrary emulated USB hardware and nothing else on a
|
||||||
|
Deck uses it: `sudo gpasswd -d "$USER" punktfunk; sudo groupdel punktfunk`.
|
||||||
|
|
||||||
### NixOS
|
### NixOS
|
||||||
|
|
||||||
@@ -184,9 +196,9 @@ There is nothing to uninstall imperatively — remove what you declared:
|
|||||||
input.
|
input.
|
||||||
3. Rebuild: `sudo nixos-rebuild switch`.
|
3. Rebuild: `sudo nixos-rebuild switch`.
|
||||||
|
|
||||||
The unit, udev rules, sysctl tuning, firewall ports and `input` group membership all disappear with
|
The unit, udev rules, sysctl tuning, firewall ports and the `input` / `punktfunk` group memberships
|
||||||
the generation. The store paths stay until you garbage-collect, and `~/.config/punktfunk` — which
|
all disappear with the generation. The store paths stay until you garbage-collect, and
|
||||||
the module never managed — stays regardless.
|
`~/.config/punktfunk` — which the module never managed — stays regardless.
|
||||||
|
|
||||||
## Windows host
|
## Windows host
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ This is the largest release so far — close to four hundred changes. The short
|
|||||||
|
|
||||||
Most people need to do nothing. Check this list if any of it applies to you.
|
Most people need to do nothing. Check this list if any of it applies to you.
|
||||||
|
|
||||||
- **Linux, if you use the virtual Steam Deck controller: join a new group.** That permission used to ride on `input`, which every gamepad guide tells you to join — but it can emulate arbitrary USB hardware, so it now has its own. Run `sudo usermod -aG punktfunk "$USER"` and log back in, or the virtual Deck pad stops attaching. Ordinary virtual gamepads are unaffected, and you should only join this group on a machine you trust.
|
- **Linux hosts, if you use the virtual Steam Deck controller: join a new group.** That permission used to ride on `input`, which every gamepad guide tells you to join — but it can emulate arbitrary USB hardware, so it now has its own. Run `sudo usermod -aG punktfunk "$USER"` and log back in, or the virtual Deck pad stops attaching. Ordinary virtual gamepads are unaffected, and you should only join this group on a machine you trust. This is a host-side step: a Linux machine you only *stream from* needs nothing. **If that command reports `group 'punktfunk' does not exist`**, you are on one of the install paths that shipped 0.25.0 without creating it — an Arch box upgraded rather than freshly installed, NixOS, the Bazzite sysext, or a Steam Deck source install. `sudo groupadd --system punktfunk` first, then the `usermod`; a later update creates it for you.
|
||||||
- **Add-on interfaces moved to their own port (47993).** An existing firewall rule will not pick it up when you upgrade, and the symptom is a blank panel where the add-on's interface should be. On Linux the package prints the exact command — `sudo ufw app update punktfunk-web && sudo ufw reload`, or a firewalld reload. With Docker, publish `47993` as well. If you reach your console over a self-signed certificate, your browser needs to trust the new port once; the console shows a card with a link that does it.
|
- **Add-on interfaces moved to their own port (47993).** An existing firewall rule will not pick it up when you upgrade, and the symptom is a blank panel where the add-on's interface should be. On Linux the package prints the exact command — `sudo ufw app update punktfunk-web && sudo ufw reload`, or a firewalld reload. With Docker, publish `47993` as well. If you reach your console over a self-signed certificate, your browser needs to trust the new port once; the console shows a card with a link that does it.
|
||||||
- **Windows hosts now need Steam installed** for streamed audio — it never has to run. Without it the host streams video only, and picks the drivers up on its own if you install Steam later. Two new devices, "Punktfunk Speakers" and "Punktfunk Microphone", will appear in your sound settings; that is this feature working. If you already have VB-CABLE, leave it — it still works as a fallback and is not removed.
|
- **Windows hosts now need Steam installed** for streamed audio — it never has to run. Without it the host streams video only, and picks the drivers up on its own if you install Steam later. Two new devices, "Punktfunk Speakers" and "Punktfunk Microphone", will appear in your sound settings; that is this feature working. If you already have VB-CABLE, leave it — it still works as a fallback and is not removed.
|
||||||
- **Saving a game with a custom launch command asks for your console password again**, and add-ons may no longer set launch commands at all. A third-party add-on that did will need updating by its author.
|
- **Saving a game with a custom launch command asks for your console password again**, and add-ons may no longer set launch commands at all. A third-party add-on that did will need updating by its author.
|
||||||
- **A fresh install now runs the add-on runner by default.** Upgrades are untouched — if you switched it off, it stays off.
|
- **A fresh install now runs the add-on runner by default.** Upgrades are untouched — if you switched it off, it stays off.
|
||||||
- **If you set up a Steam Deck with the install script, consider rotating your console password.** It was written to a world-readable file; that is fixed.
|
- **If you set up a Steam Deck with the install script, rotate your console password.** It was written to a world-readable file, so any local account could read it — and fixing the permissions does not un-share a password that was already readable, which is why this one is worth actually doing rather than considering. New installs are written correctly. On an existing one, re-run `install.sh` or `update.sh` to tighten the file, then change `PUNKTFUNK_UI_PASSWORD` in `~/.config/punktfunk/web.env` and `systemctl --user restart punktfunk-web`.
|
||||||
- **If you play with motion controls, your aim sensitivity will change.** The gyro pipeline was wrong at every stage and is now measured against a real controller, so the numbers moved: a controller presented to games as a DualShock 4 was reporting motion **forty times too fast**, and a PlayStation pad plugged into an Android phone was reporting about **30% short**. If you turned a game's sensitivity down or up to cope, set it back. The Android case is the one people plausibly tuned around — that aim now needs a *higher* in-game sensitivity than you are used to.
|
- **If you play with motion controls, your aim sensitivity will change.** The gyro pipeline was wrong at every stage and is now measured against a real controller, so the numbers moved: a controller presented to games as a DualShock 4 was reporting motion **forty times too fast**, and a PlayStation pad plugged into an Android phone was reporting about **30% short**. If you turned a game's sensitivity down or up to cope, set it back. The Android case is the one people plausibly tuned around — that aim now needs a *higher* in-game sensitivity than you are used to.
|
||||||
|
|
||||||
## New
|
## New
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ MSG
|
|||||||
|
|
||||||
post_upgrade() {
|
post_upgrade() {
|
||||||
_ensure_update_group
|
_ensure_update_group
|
||||||
|
# Also on UPGRADE, not just post_install: 'punktfunk' was introduced in 0.25.0, so every box that
|
||||||
|
# reached it by `pacman -Syu` from 0.24.x ran only this function and never got the group at all —
|
||||||
|
# leaving 60-punktfunk.rules to chgrp to a nonexistent group, the vhci attach/detach nodes
|
||||||
|
# root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so
|
||||||
|
# this is a no-op on boxes that installed fresh.
|
||||||
|
_ensure_punktfunk_group
|
||||||
udevadm control --reload-rules 2>/dev/null || true
|
udevadm control --reload-rules 2>/dev/null || true
|
||||||
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
|
||||||
_warn_stale_firewall_ports
|
_warn_stale_firewall_ports
|
||||||
|
|||||||
@@ -167,6 +167,13 @@ post_merge() {
|
|||||||
# The (empty) opt-in group for web-console-triggered updates (the sysext ships the pf-update
|
# The (empty) opt-in group for web-console-triggered updates (the sysext ships the pf-update
|
||||||
# helper + unit + polkit rule in its /usr; the group can't ride an image) — nobody is auto-added.
|
# helper + unit + polkit rule in its /usr; the group can't ride an image) — nobody is auto-added.
|
||||||
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
|
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || :
|
||||||
|
# 'punktfunk' owns the vhci attach/detach nodes the rule we just mirrored into /etc chgrp's to.
|
||||||
|
# A group cannot ride an image either (/etc/group is host state), and the deb/rpm scriptlets that
|
||||||
|
# would normally create it never run on an image-based install — so without this the chgrp fails,
|
||||||
|
# attach/detach stay root-only and the virtual Steam Deck pad never attaches. Deliberately NOT
|
||||||
|
# 'input': writing 'attach' materialises an arbitrary emulated USB device (review 2026-08-05 M-4),
|
||||||
|
# so it stays a group users join on purpose — see `ujust add-user-to-input-group` for the other one.
|
||||||
|
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || :
|
||||||
modprobe vhci-hcd 2>/dev/null || :
|
modprobe vhci-hcd 2>/dev/null || :
|
||||||
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
|
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
|
||||||
# the input-group ownership even when the module's original add event predated the reloaded rule.
|
# the input-group ownership even when the module's original add event predated the reloaded rule.
|
||||||
|
|||||||
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Package an already-built punktfunk-gamescope binary as a .deb, for the Gitea apt registry.
|
||||||
|
#
|
||||||
|
# Counterpart to ../gamescope/build-gamescope-rpm.sh, and the same argument: the binary is a
|
||||||
|
# ~10-minute meson build of an unrelated tree that CI does once and caches, so this repacks rather
|
||||||
|
# than rebuilds. The Arch package (../gamescope/PKGBUILD) is the one recipe that builds from source,
|
||||||
|
# because that is what makepkg is for.
|
||||||
|
#
|
||||||
|
# Installed as /usr/bin/punktfunk-gamescope — it does NOT replace the distro's gamescope, and does
|
||||||
|
# not Provide/Conflict with it. Only the sessions punktfunk-host starts itself resolve this binary
|
||||||
|
# (PUNKTFUNK_GAMESCOPE_BIN > punktfunk-gamescope > gamescope).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# VERSION=3.16.25.pfhdr4~ci42.gdeadbee bash packaging/debian/build-gamescope-deb.sh \
|
||||||
|
# --binary gs-cache/punktfunk-gamescope [--arch amd64]
|
||||||
|
# Output: dist/punktfunk-gamescope_<version>_<arch>.deb
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BINARY=""
|
||||||
|
DEB_ARCH=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
|
||||||
|
--arch) DEB_ARCH="${2:?--arch needs a value}"; shift 2 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
|
||||||
|
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
|
||||||
|
|
||||||
|
PKG="punktfunk-gamescope"
|
||||||
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
|
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
|
||||||
|
|
||||||
|
# The marker is the host's whole capability probe: a binary that lost the patches installs fine and
|
||||||
|
# then silently streams SDR, cursorless, at a 60 Hz-advertising session. Refuse to package it.
|
||||||
|
BANNER="$("$BINARY" --version 2>&1 | head -1)"
|
||||||
|
case "$BANNER" in
|
||||||
|
*'+pfhdr'*) ;;
|
||||||
|
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
|
||||||
|
echo " banner: $BANNER" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Derive the version from the binary when the caller did not pass one — it is the only source that
|
||||||
|
# cannot drift from what is actually in the package.
|
||||||
|
if [ -z "${VERSION:-}" ]; then
|
||||||
|
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
|
||||||
|
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
|
||||||
|
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
|
||||||
|
VERSION="${UPSTREAM}.${PFHDR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
# mktemp gives 0700; the package root has to be world-readable or `dpkg-deb -c` shows the tree as
|
||||||
|
# root-only and some tooling refuses it.
|
||||||
|
chmod 0755 "$STAGE"
|
||||||
|
install -Dm0755 "$BINARY" "$STAGE/usr/bin/punktfunk-gamescope"
|
||||||
|
mkdir -p "$STAGE/DEBIAN"
|
||||||
|
|
||||||
|
# Shared-library dependencies straight from the binary's own ELF NEEDED entries. That is what makes
|
||||||
|
# the package honest about the Ubuntu release it was compiled on: gamescope links a broad set
|
||||||
|
# (wlroots, SDL, libliftoff, vulkan, xwayland's libs), and hand-listing them would rot.
|
||||||
|
DEPS=""
|
||||||
|
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
|
||||||
|
# dpkg-shlibdeps insists on running from a package root with a debian/ dir.
|
||||||
|
mkdir -p "$STAGE/debian"
|
||||||
|
: > "$STAGE/debian/control"
|
||||||
|
( cd "$STAGE" && dpkg-shlibdeps -O --ignore-missing-info usr/bin/punktfunk-gamescope 2>/dev/null ) \
|
||||||
|
> "$STAGE/.shlibdeps" || true
|
||||||
|
DEPS="$(sed -n 's/^shlibs:Depends=//p' "$STAGE/.shlibdeps" | head -1)"
|
||||||
|
rm -rf "$STAGE/debian" "$STAGE/.shlibdeps"
|
||||||
|
fi
|
||||||
|
[ -n "$DEPS" ] || echo "WARNING: dpkg-shlibdeps produced no Depends — packaging without them" >&2
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "Package: $PKG"
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
echo "Architecture: $DEB_ARCH"
|
||||||
|
echo "Maintainer: unom <packages@unom.io>"
|
||||||
|
echo "Section: utils"
|
||||||
|
echo "Priority: optional"
|
||||||
|
[ -n "$DEPS" ] && echo "Depends: $DEPS"
|
||||||
|
# Not a hard dependency in either direction: the host works without this binary (SDR,
|
||||||
|
# host-composited cursor), and someone may want the binary for their own capture consumer.
|
||||||
|
echo "Recommends: punktfunk-host"
|
||||||
|
echo "Homepage: https://git.unom.io/unom/punktfunk"
|
||||||
|
echo "Description: gamescope with punktfunk's PipeWire capture patches"
|
||||||
|
echo " gamescope built from the upstream revision punktfunk pins, plus the patches in"
|
||||||
|
echo " packaging/gamescope/patches:"
|
||||||
|
echo " ."
|
||||||
|
echo " * 10-bit BT.2020/PQ capture formats, so an HDR game reaches a capture consumer as HDR"
|
||||||
|
echo " instead of pre-tonemapped SDR."
|
||||||
|
echo " * --pipewire-composite-cursor: the pointer is painted into the capture stream, so a"
|
||||||
|
echo " consumer with no cursor of its own gets one and the host stops blending one in."
|
||||||
|
echo " * A headless session advertises its real mode and refresh rates (and"
|
||||||
|
echo " --custom-refresh-rates), so Steam and games see the resolution and refresh the stream"
|
||||||
|
echo " actually runs at instead of an unnamed 60 Hz panel."
|
||||||
|
echo " * --pipewire-composite-external-overlay: the mangoapp performance overlay is painted"
|
||||||
|
echo " into the capture stream, so the fps/stats readout is visible remotely."
|
||||||
|
echo " ."
|
||||||
|
echo " Installed as /usr/bin/punktfunk-gamescope; your system gamescope is untouched."
|
||||||
|
} > "$STAGE/DEBIAN/control"
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
|
||||||
|
dpkg-deb --build --root-owner-group "$STAGE" "$OUT"
|
||||||
|
echo "==> wrote $OUT"
|
||||||
|
echo " banner: $BANNER"
|
||||||
@@ -19,8 +19,10 @@ pkgname=punktfunk-gamescope
|
|||||||
# bump it with the marker so pacman sees a new version when only our patches moved.
|
# bump it with the marker so pacman sees a new version when only our patches moved.
|
||||||
_gsver=3.16.25
|
_gsver=3.16.25
|
||||||
_gsrev=8c676c399c761e4540587f61004c957993d12fea
|
_gsrev=8c676c399c761e4540587f61004c957993d12fea
|
||||||
pkgver="${_gsver}.pfhdr2"
|
pkgver="${_gsver}.pfhdr4"
|
||||||
pkgrel=1
|
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
|
||||||
|
# `.pfhdrN` level deliberately stays put — see README.md.
|
||||||
|
pkgrel=2
|
||||||
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
|
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
url="https://git.unom.io/unom/punktfunk"
|
url="https://git.unom.io/unom/punktfunk"
|
||||||
|
|||||||
@@ -13,7 +13,22 @@ The patches here add the missing half, and nothing else. See
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch` | Offer SPA `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 props, map them to `DRM_FORMAT_XRGB2101010`/`XBGR2101010`, and composite them with `g_ScreenshotColorMgmtLutsHDR` + `EOTF_PQ` | **Yes** — offered against [gamescope#2126](https://github.com/ValveSoftware/gamescope/issues/2126) |
|
| `0001-pipewire-offer-10-bit-BT.2020-PQ-capture-formats-HDR.patch` | Offer SPA `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 props, map them to `DRM_FORMAT_XRGB2101010`/`XBGR2101010`, and composite them with `g_ScreenshotColorMgmtLutsHDR` + `EOTF_PQ` | **Yes** — offered against [gamescope#2126](https://github.com/ValveSoftware/gamescope/issues/2126) |
|
||||||
| `0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch` | `--pipewire-composite-cursor` (off by default): paint the pointer into the capture stream, using the same `MouseCursor::paint` call the scanout composite uses | **Yes** — independently useful to any consumer with no cursor of its own |
|
| `0002-pipewire-optionally-composite-the-cursor-into-the-ca.patch` | `--pipewire-composite-cursor` (off by default): paint the pointer into the capture stream, using the same `MouseCursor::paint` call the scanout composite uses | **Yes** — independently useful to any consumer with no cursor of its own |
|
||||||
| `0003-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the two above land upstream |
|
| `0003-headless-advertise-the-virtual-display-s-mode-and-re.patch` | Give `CHeadlessConnector` a real `GetModes()` + `GetValidDynamicRefreshRates()` from the resolved `-W`/`-H`/`-r`, report `GAMESCOPE_SCREEN_TYPE_EXTERNAL` so `update_mode_atoms` publishes the list, and add `--custom-refresh-rates` | **Yes** — a headless session that cannot report its own mode is a plain bug |
|
||||||
|
| `0004-pipewire-optionally-composite-the-external-overlay-i.patch` | `--pipewire-composite-external-overlay` (off by default): paint the external overlay layer (mangoapp — the fps/stats readout) into the capture stream | **Yes** — same shape as the cursor patch, same argument |
|
||||||
|
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
|
||||||
|
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
|
||||||
|
|
||||||
|
### Why the headless patch matters
|
||||||
|
|
||||||
|
A headless gamescope is how a streaming host gives a game a display: the caller passes the
|
||||||
|
client's exact mode and expects the session to run at it. It *does* — but it never told anyone.
|
||||||
|
`CHeadlessConnector` returned an empty span from both `GetModes()` and
|
||||||
|
`GetValidDynamicRefreshRates()` and reported `GAMESCOPE_SCREEN_TYPE_INTERNAL`, so
|
||||||
|
`update_mode_atoms()` **deleted** `GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL` (no resolution list) and
|
||||||
|
`wlserver_send_gamescope_control()` fell through to a **one-entry** refresh list built from
|
||||||
|
`g_nOutputRefresh` (no refresh list). With `-r` absent that entry is `Init()`'s 60 Hz default, so a
|
||||||
|
client on a 120 Hz panel was told its display was 60 Hz — and games capped themselves to it. Field
|
||||||
|
report 2026-08-08: "gamescope only shows 60hz and there's no other option".
|
||||||
|
|
||||||
### Why the cursor patch matters more than it looks
|
### Why the cursor patch matters more than it looks
|
||||||
|
|
||||||
@@ -40,9 +55,17 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `+pfhdr1` | 10-bit BT.2020/PQ capture formats |
|
| `+pfhdr1` | 10-bit BT.2020/PQ capture formats |
|
||||||
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
|
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
|
||||||
|
| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` |
|
||||||
|
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
|
||||||
|
|
||||||
Bump it whenever a patch adds or changes something the host must know about before it spawns.
|
Bump it whenever a patch adds or changes something the host must know about before it spawns.
|
||||||
|
|
||||||
|
A patch that only fixes a crash does **not** bump it: `0006` (the exit-time Vulkan teardown fix)
|
||||||
|
changes nothing the host probes for, so the level stays `+pfhdr4` and the rebuild ships as a
|
||||||
|
`pkgrel` bump instead — exactly the split the PKGBUILD's own comment describes. Bumping the level
|
||||||
|
for a bugfix would be worse than useless: it would advertise a capability tier that does not exist
|
||||||
|
and strand hosts that gate on it.
|
||||||
|
|
||||||
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
|
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
|
||||||
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
|
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
|
||||||
session ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope, it gets neither the HDR
|
session ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope, it gets neither the HDR
|
||||||
@@ -142,7 +165,7 @@ Note what is NOT in that table: the `.deb`. Debian/Ubuntu boxes build it by hand
|
|||||||
## Verifying the patch on a box (P0 exit)
|
## Verifying the patch on a box (P0 exit)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
punktfunk-gamescope --version # must contain +pfhdr2
|
punktfunk-gamescope --version # must contain +pfhdr4
|
||||||
punktfunk-gamescope --backend headless -W 1920 -H 1080 -r 60 \
|
punktfunk-gamescope --backend headless -W 1920 -H 1080 -r 60 \
|
||||||
--hdr-enabled --hdr-debug-force-support --pipewire-composite-cursor -- vkcube &
|
--hdr-enabled --hdr-debug-force-support --pipewire-composite-cursor -- vkcube &
|
||||||
pw-dump | grep -A40 '"gamescope"' # node offers xRGB_210LE / xBGR_210LE
|
pw-dump | grep -A40 '"gamescope"' # node offers xRGB_210LE / xBGR_210LE
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Package an already-built punktfunk-gamescope binary as an RPM, for the Gitea RPM registry.
|
||||||
|
#
|
||||||
|
# WHY this exists: before it, the only ways to get punktfunk-gamescope were the Bazzite/Atomic
|
||||||
|
# sysext, the Arch package, the SteamOS installer, a NixOS option — or building gamescope from
|
||||||
|
# source yourself. A traditional Fedora-family box (Nobara, plain Fedora, Nobara-derived HTPCs)
|
||||||
|
# had no packaged route at all, which is how a field report ended up on a stock gamescope streaming
|
||||||
|
# a session that told every game the display was 60 Hz.
|
||||||
|
#
|
||||||
|
# The binary is NOT built here; CI builds it once per Fedora major and caches it
|
||||||
|
# (.gitea/workflows/rpm.yml). See punktfunk-gamescope.spec's header for why repacking beats
|
||||||
|
# rebuilding.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash packaging/gamescope/build-gamescope-rpm.sh \
|
||||||
|
# --binary gs-cache/punktfunk-gamescope \
|
||||||
|
# [--version 3.16.25] [--release 1] [--outdir dist]
|
||||||
|
#
|
||||||
|
# Output: <outdir>/punktfunk-gamescope-<version>-<release>.<arch>.rpm
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BINARY=""
|
||||||
|
# Default the version to the upstream gamescope the pinned revision describes as, suffixed with the
|
||||||
|
# patch-set revision — same shape as the Arch package's `pkgver`, so the two channels read alike.
|
||||||
|
VERSION=""
|
||||||
|
RELEASE="1"
|
||||||
|
OUTDIR="dist"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--binary) BINARY="${2:?--binary needs a path}"; shift 2 ;;
|
||||||
|
--version) VERSION="${2:?--version needs a value}"; shift 2 ;;
|
||||||
|
--release) RELEASE="${2:?--release needs a value}"; shift 2 ;;
|
||||||
|
--outdir) OUTDIR="${2:?--outdir needs a value}"; shift 2 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$BINARY" ] || { echo "ERROR: --binary is required" >&2; exit 2; }
|
||||||
|
[ -x "$BINARY" ] || { echo "ERROR: $BINARY is not an executable file" >&2; exit 1; }
|
||||||
|
|
||||||
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
|
# Derive the version from the binary itself when not told: it is the only source that cannot drift
|
||||||
|
# from what is actually being packaged. `gamescope version 3.16.25-1-g8c676c3+pfhdr4 (gcc …)` →
|
||||||
|
# `3.16.25` + the marker. RPM versions may not contain `-`, hence the trailing `.pfhdrN` form.
|
||||||
|
BANNER="$("$BINARY" --version 2>&1 | head -1)"
|
||||||
|
case "$BANNER" in
|
||||||
|
*'+pfhdr'*) ;;
|
||||||
|
*) echo "ERROR: $BINARY has no +pfhdr marker — it is not a punktfunk gamescope build" >&2
|
||||||
|
echo " banner: $BANNER" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
PFHDR="$(printf '%s\n' "$BANNER" | grep -o '+pfhdr[0-9]\+' | head -1 | tr -d '+')"
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
UPSTREAM="$(printf '%s\n' "$BANNER" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -1)"
|
||||||
|
[ -n "$UPSTREAM" ] || { echo "ERROR: no X.Y.Z version in banner: $BANNER" >&2; exit 1; }
|
||||||
|
VERSION="${UPSTREAM}.${PFHDR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> packaging $BINARY as punktfunk-gamescope-${VERSION}-${RELEASE}"
|
||||||
|
echo " banner: $BANNER"
|
||||||
|
|
||||||
|
TOP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TOP"' EXIT
|
||||||
|
mkdir -p "$TOP"/{SOURCES,SPECS,BUILD,BUILDROOT,RPMS,SRPMS}
|
||||||
|
install -m0755 "$BINARY" "$TOP/SOURCES/punktfunk-gamescope"
|
||||||
|
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
rpmbuild \
|
||||||
|
--define "_topdir $TOP" \
|
||||||
|
--define "pf_version $VERSION" \
|
||||||
|
--define "pf_release $RELEASE" \
|
||||||
|
-bb packaging/gamescope/punktfunk-gamescope.spec
|
||||||
|
|
||||||
|
find "$TOP/RPMS" -name '*.rpm' -exec cp -v {} "$OUTDIR/" \;
|
||||||
|
echo "==> wrote $(find "$OUTDIR" -name 'punktfunk-gamescope-*.rpm' -newer "$TOP" -print -quit 2>/dev/null || echo "$OUTDIR"/punktfunk-gamescope-*.rpm)"
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||||
|
Date: Sat, 8 Aug 2026 13:13:09 +0000
|
||||||
|
Subject: [PATCH] headless: advertise the virtual display's mode and refresh
|
||||||
|
rates
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
|
||||||
|
A headless gamescope is how a remote-desktop/streaming host gives a game a
|
||||||
|
display: the caller passes the client's exact mode with -W/-H/-r and expects
|
||||||
|
the session to run at it. It does composite at that rate — but it never told
|
||||||
|
anyone. CHeadlessConnector returned an empty span from both GetModes() and
|
||||||
|
GetValidDynamicRefreshRates(), and reported GAMESCOPE_SCREEN_TYPE_INTERNAL.
|
||||||
|
|
||||||
|
Both halves of that are visible to clients:
|
||||||
|
|
||||||
|
* update_mode_atoms() takes the INTERNAL branch, which DELETES
|
||||||
|
GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL — so there is no resolution list.
|
||||||
|
* wlserver_send_gamescope_control() finds no valid dynamic refresh rates and
|
||||||
|
falls through to a one-entry list built from g_nOutputRefresh — so there is
|
||||||
|
no refresh list either, just whatever the session happens to run at. With
|
||||||
|
-r absent that is the 60 Hz default from Init(), and a client on a 120 Hz
|
||||||
|
panel is told its display is 60 Hz and caps itself accordingly.
|
||||||
|
|
||||||
|
Populate both from the mode Init() has already resolved, and report EXTERNAL:
|
||||||
|
a virtual display is not a built-in panel, and INTERNAL is what suppressed the
|
||||||
|
mode list in the first place. GetConnector() follows so a lookup by type cannot
|
||||||
|
contradict the connector's own answer.
|
||||||
|
|
||||||
|
--custom-refresh-rates lists the rates the display may switch between, for a
|
||||||
|
backend that has no EDID to derive them from (gamescope-session-plus already
|
||||||
|
passes this env through, gated on the flag existing). The running rate is always
|
||||||
|
included, so the advertised set can never exclude the mode in use.
|
||||||
|
|
||||||
|
Only the headless backend changes; every other backend derives its modes from a
|
||||||
|
real connector and is untouched.
|
||||||
|
---
|
||||||
|
src/Backends/HeadlessBackend.cpp | 66 ++++++++++++++++++++++++++++++--
|
||||||
|
src/main.cpp | 41 ++++++++++++++++++++
|
||||||
|
2 files changed, 103 insertions(+), 4 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/src/Backends/HeadlessBackend.cpp b/src/Backends/HeadlessBackend.cpp
|
||||||
|
index 8e45400..3168c88 100644
|
||||||
|
--- a/src/Backends/HeadlessBackend.cpp
|
||||||
|
+++ b/src/Backends/HeadlessBackend.cpp
|
||||||
|
@@ -3,8 +3,13 @@
|
||||||
|
#include "wlserver.hpp"
|
||||||
|
#include "refresh_rate.h"
|
||||||
|
|
||||||
|
+#include <algorithm>
|
||||||
|
+#include <vector>
|
||||||
|
+
|
||||||
|
extern int g_nPreferredOutputWidth;
|
||||||
|
extern int g_nPreferredOutputHeight;
|
||||||
|
+// `--custom-refresh-rates` (main.cpp): the rates this virtual display may switch between.
|
||||||
|
+extern std::vector<uint32_t> g_customRefreshRates;
|
||||||
|
|
||||||
|
namespace gamescope
|
||||||
|
{
|
||||||
|
@@ -18,9 +23,16 @@ namespace gamescope
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // A headless gamescope IS a virtual display: its mode is whatever the caller asked
|
||||||
|
+ // for (`-W`/`-H`/`-r`), never a fixed built-in panel. Reporting INTERNAL made
|
||||||
|
+ // `update_mode_atoms` DELETE GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL and set the
|
||||||
|
+ // GAMESCOPE_CONTROL_DISPLAY_FLAG_INTERNAL_DISPLAY flag, so a client driving this
|
||||||
|
+ // session was offered no resolutions at all — and, with the empty rate list below,
|
||||||
|
+ // no refresh rates either. EXTERNAL is both the honest answer and the one that lets
|
||||||
|
+ // the mode list reach Steam.
|
||||||
|
virtual gamescope::GamescopeScreenType GetScreenType() const override
|
||||||
|
{
|
||||||
|
- return GAMESCOPE_SCREEN_TYPE_INTERNAL;
|
||||||
|
+ return GAMESCOPE_SCREEN_TYPE_EXTERNAL;
|
||||||
|
}
|
||||||
|
virtual GamescopePanelOrientation GetCurrentOrientation() const override
|
||||||
|
{
|
||||||
|
@@ -44,7 +56,7 @@ namespace gamescope
|
||||||
|
}
|
||||||
|
virtual std::span<const BackendMode> GetModes() const override
|
||||||
|
{
|
||||||
|
- return std::span<const BackendMode>{};
|
||||||
|
+ return m_Modes;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual bool SupportsVRR() const override
|
||||||
|
@@ -58,7 +70,7 @@ namespace gamescope
|
||||||
|
}
|
||||||
|
virtual std::span<const uint32_t> GetValidDynamicRefreshRates() const override
|
||||||
|
{
|
||||||
|
- return std::span<const uint32_t>{};
|
||||||
|
+ return m_ValidDynamicRefreshRates;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void GetNativeColorimetry(
|
||||||
|
@@ -90,8 +102,42 @@ namespace gamescope
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Publish the mode this session was actually started with, plus every refresh rate it
|
||||||
|
+ // is allowed to switch between. Called once from CHeadlessBackend::Init(), after the
|
||||||
|
+ // -W/-H/-r defaults are resolved, because that is the first point at which the answer
|
||||||
|
+ // exists — and it has to exist before the first `gamescope_control` bind, which is
|
||||||
|
+ // what hands Steam the list.
|
||||||
|
+ void SetVirtualMode( uint32_t uWidth, uint32_t uHeight, uint32_t uRefreshHz,
|
||||||
|
+ std::span<const uint32_t> uOfferRatesHz )
|
||||||
|
+ {
|
||||||
|
+ m_ValidDynamicRefreshRates.clear();
|
||||||
|
+ m_Modes.clear();
|
||||||
|
+
|
||||||
|
+ auto AddRate = [ this ]( uint32_t uRate )
|
||||||
|
+ {
|
||||||
|
+ if ( !uRate )
|
||||||
|
+ return;
|
||||||
|
+ if ( std::find( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end(), uRate )
|
||||||
|
+ == m_ValidDynamicRefreshRates.end() )
|
||||||
|
+ m_ValidDynamicRefreshRates.push_back( uRate );
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ for ( uint32_t uRate : uOfferRatesHz )
|
||||||
|
+ AddRate( uRate );
|
||||||
|
+ // The rate we are running at is always offerable, whatever the caller listed —
|
||||||
|
+ // otherwise Steam is handed a set that excludes the mode it is looking at.
|
||||||
|
+ AddRate( uRefreshHz );
|
||||||
|
+
|
||||||
|
+ std::sort( m_ValidDynamicRefreshRates.begin(), m_ValidDynamicRefreshRates.end() );
|
||||||
|
+
|
||||||
|
+ for ( uint32_t uRate : m_ValidDynamicRefreshRates )
|
||||||
|
+ m_Modes.push_back( BackendMode{ uWidth, uHeight, uRate } );
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
private:
|
||||||
|
BackendConnectorHDRInfo m_HDRInfo{};
|
||||||
|
+ std::vector<BackendMode> m_Modes;
|
||||||
|
+ std::vector<uint32_t> m_ValidDynamicRefreshRates;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CHeadlessBackend final : public CBaseBackend
|
||||||
|
@@ -125,6 +171,16 @@ namespace gamescope
|
||||||
|
if ( g_nOutputRefresh == 0 )
|
||||||
|
g_nOutputRefresh = ConvertHztomHz( 60 );
|
||||||
|
|
||||||
|
+ // Hand the connector the resolved mode. Until this existed the headless connector
|
||||||
|
+ // advertised NOTHING — no modes, no dynamic refresh rates — so `wlserver`'s
|
||||||
|
+ // `active_display_info` fell through to a one-entry list built from g_nOutputRefresh
|
||||||
|
+ // and every client concluded the display was a 60 Hz panel it could not change.
|
||||||
|
+ m_Connector.SetVirtualMode(
|
||||||
|
+ uint32_t( g_nOutputWidth ),
|
||||||
|
+ uint32_t( g_nOutputHeight ),
|
||||||
|
+ ConvertmHzToHz( uint32_t( g_nOutputRefresh ) ),
|
||||||
|
+ g_customRefreshRates );
|
||||||
|
+
|
||||||
|
if ( !vulkan_init( vulkan_get_instance(), VK_NULL_HANDLE ) )
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
@@ -200,7 +256,9 @@ namespace gamescope
|
||||||
|
}
|
||||||
|
virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override
|
||||||
|
{
|
||||||
|
- if ( eScreenType == GAMESCOPE_SCREEN_TYPE_INTERNAL )
|
||||||
|
+ // Must agree with CHeadlessConnector::GetScreenType() — a lookup by type that
|
||||||
|
+ // contradicted the connector's own answer would hand callers the wrong screen.
|
||||||
|
+ if ( eScreenType == GAMESCOPE_SCREEN_TYPE_EXTERNAL )
|
||||||
|
return &m_Connector;
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
diff --git a/src/main.cpp b/src/main.cpp
|
||||||
|
index 1eb35b3..2c4fb50 100644
|
||||||
|
--- a/src/main.cpp
|
||||||
|
+++ b/src/main.cpp
|
||||||
|
@@ -92,6 +92,7 @@ const struct option *gamescope_options = (struct option[]){
|
||||||
|
{ "prefer-output", required_argument, nullptr, 'O' },
|
||||||
|
{ "default-touch-mode", required_argument, nullptr, 0 },
|
||||||
|
{ "generate-drm-mode", required_argument, nullptr, 0 },
|
||||||
|
+ { "custom-refresh-rates", required_argument, nullptr, 0 },
|
||||||
|
{ "immediate-flips", no_argument, nullptr, 0 },
|
||||||
|
{ "framerate-limit", required_argument, nullptr, 0 },
|
||||||
|
|
||||||
|
@@ -232,6 +233,7 @@ const char usage[] =
|
||||||
|
" -O, --prefer-output list of connectors in order of preference (ex: DP-1,DP-2,DP-3,HDMI-A-1)\n"
|
||||||
|
" --default-touch-mode 0: hover, 1: left, 2: right, 3: middle, 4: passthrough\n"
|
||||||
|
" --generate-drm-mode DRM mode generation algorithm (cvt, fixed)\n"
|
||||||
|
+ " --custom-refresh-rates comma-separated refresh rates (Hz) this display may switch between, eg. 60,90,120 (headless only)\n"
|
||||||
|
" --immediate-flips Enable immediate flips, may result in tearing\n"
|
||||||
|
"\n"
|
||||||
|
#if HAVE_OPENVR
|
||||||
|
@@ -297,6 +299,10 @@ int g_nNestedHeight = 0;
|
||||||
|
int g_nNestedRefresh = 0;
|
||||||
|
int g_nNestedUnfocusedRefresh = 0;
|
||||||
|
int g_nNestedDisplayIndex = 0;
|
||||||
|
+// `--custom-refresh-rates`, in Hz. Consumed by the headless backend, which has no EDID to
|
||||||
|
+// derive a mode list from and so cannot answer "what else could this display run at" on its
|
||||||
|
+// own. Empty = offer only the rate the session was started at.
|
||||||
|
+std::vector<uint32_t> g_customRefreshRates;
|
||||||
|
|
||||||
|
uint32_t g_nOutputWidth = 0;
|
||||||
|
uint32_t g_nOutputHeight = 0;
|
||||||
|
@@ -447,6 +453,39 @@ static enum gamescope::GamescopeBackend parse_backend_name(const char *str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+// `--custom-refresh-rates 60,90,120` -> { 60, 90, 120 }. Anything unparseable is a hard error,
|
||||||
|
+// exactly like every other option value: silently dropping a rate would leave a display
|
||||||
|
+// advertising a list the caller never asked for, which is worse than not starting.
|
||||||
|
+static std::vector<uint32_t> parse_refresh_rate_list(const char *str)
|
||||||
|
+{
|
||||||
|
+ std::vector<uint32_t> rates;
|
||||||
|
+ std::string_view svRest{ str };
|
||||||
|
+ while ( !svRest.empty() )
|
||||||
|
+ {
|
||||||
|
+ const size_t nComma = svRest.find( ',' );
|
||||||
|
+ std::string_view svTok = svRest.substr( 0, nComma );
|
||||||
|
+ svRest = nComma == std::string_view::npos ? std::string_view{} : svRest.substr( nComma + 1 );
|
||||||
|
+
|
||||||
|
+ // `Parse` is `std::from_chars`, which rejects leading blanks outright — trim so that a
|
||||||
|
+ // perfectly ordinary "60, 90, 120" is not an error.
|
||||||
|
+ while ( !svTok.empty() && svTok.front() == ' ' )
|
||||||
|
+ svTok.remove_prefix( 1 );
|
||||||
|
+ while ( !svTok.empty() && svTok.back() == ' ' )
|
||||||
|
+ svTok.remove_suffix( 1 );
|
||||||
|
+
|
||||||
|
+ std::optional<uint32_t> oRate = gamescope::Parse<uint32_t>( svTok );
|
||||||
|
+ // 1000 Hz is not a limit anyone will meet; it is there so a typo'd "1920" cannot become a
|
||||||
|
+ // refresh rate that every consumer then has to sanity-check for us.
|
||||||
|
+ if ( !oRate || *oRate == 0 || *oRate > 1000 )
|
||||||
|
+ {
|
||||||
|
+ fprintf( stderr, "gamescope: invalid value for --custom-refresh-rates: %s\n", str );
|
||||||
|
+ exit( 1 );
|
||||||
|
+ }
|
||||||
|
+ rates.push_back( *oRate );
|
||||||
|
+ }
|
||||||
|
+ return rates;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
static int parse_integer(const char *str, const char *optionName)
|
||||||
|
{
|
||||||
|
auto result = gamescope::Parse<int>(str);
|
||||||
|
@@ -800,6 +839,8 @@ int main(int argc, char **argv)
|
||||||
|
gamescope::cv_touch_click_mode = (gamescope::TouchClickMode) parse_integer( optarg, opt_name );
|
||||||
|
} else if (strcmp(opt_name, "generate-drm-mode") == 0) {
|
||||||
|
g_eGamescopeModeGeneration = parse_gamescope_mode_generation( optarg );
|
||||||
|
+ } else if (strcmp(opt_name, "custom-refresh-rates") == 0) {
|
||||||
|
+ g_customRefreshRates = parse_refresh_rate_list( optarg );
|
||||||
|
} else if (strcmp(opt_name, "force-orientation") == 0) {
|
||||||
|
g_DesiredInternalOrientation = force_orientation( optarg );
|
||||||
|
} else if (strcmp(opt_name, "sharpness") == 0 ||
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
|
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||||
|
Date: Sat, 8 Aug 2026 13:17:14 +0000
|
||||||
|
Subject: [PATCH] pipewire: optionally composite the external overlay into the
|
||||||
|
capture stream
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
|
||||||
|
paint_pipewire() is a separate, reduced composite from the scanout one. It
|
||||||
|
paints the focus window, the override window and — since 3.16.23 — the Steam
|
||||||
|
overlay, but it has never referenced externalOverlayWindow. That layer is
|
||||||
|
mangoapp: the fps / frametime / stats readout the Deck UI turns on.
|
||||||
|
|
||||||
|
On a real handheld the omission is invisible, because the person who enabled
|
||||||
|
the overlay is looking at the panel it is drawn on. For a consumer whose ONLY
|
||||||
|
view of the session is this node — a remote-desktop or streaming host — the
|
||||||
|
overlay simply does not exist: the user turns it on, sees nothing, and there is
|
||||||
|
nothing they can configure to change that.
|
||||||
|
|
||||||
|
Add --pipewire-composite-external-overlay, off by default for the same reason
|
||||||
|
--pipewire-composite-cursor is: the node has never carried this layer, and a
|
||||||
|
consumer showing the stream to the same person already looking at the screen
|
||||||
|
would get two of them.
|
||||||
|
|
||||||
|
Two details worth naming:
|
||||||
|
|
||||||
|
- The overlay's commit id joins the repaint test. Its numbers change every
|
||||||
|
frame precisely while the picture behind them is static, which is exactly
|
||||||
|
the case the existing focus/override-only test skips — without this the
|
||||||
|
stream would show a frozen overlay.
|
||||||
|
- It is painted WITHOUT NoScale, unlike paint_all. There the overlay is
|
||||||
|
already sized to the output; here currentOutputWidth/Height are the capture
|
||||||
|
size, so a stream captured at another resolution needs it scaled to match.
|
||||||
|
|
||||||
|
Notifications are deliberately left out: unlike a performance overlay, they are
|
||||||
|
not something the viewer asked to see on that screen.
|
||||||
|
---
|
||||||
|
src/main.cpp | 3 +++
|
||||||
|
src/steamcompmgr.cpp | 39 +++++++++++++++++++++++++++++++++++++++
|
||||||
|
2 files changed, 42 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/src/main.cpp b/src/main.cpp
|
||||||
|
index 2c4fb50..b406caf 100644
|
||||||
|
--- a/src/main.cpp
|
||||||
|
+++ b/src/main.cpp
|
||||||
|
@@ -143,6 +143,7 @@ const struct option *gamescope_options = (struct option[]){
|
||||||
|
{ "disable-color-management", no_argument, nullptr, 0 },
|
||||||
|
{ "sdr-gamut-wideness", required_argument, nullptr, 0 },
|
||||||
|
{ "pipewire-composite-cursor", no_argument, nullptr, 0 },
|
||||||
|
+ { "pipewire-composite-external-overlay", no_argument, nullptr, 0 },
|
||||||
|
{ "hdr-enabled", no_argument, nullptr, 0 },
|
||||||
|
{ "hdr-sdr-content-nits", required_argument, nullptr, 0 },
|
||||||
|
{ "hdr-itm-enabled", no_argument, nullptr, 0 },
|
||||||
|
@@ -208,6 +209,8 @@ const char usage[] =
|
||||||
|
" --cursor-scale-height if specified, sets a base output height to linearly scale the cursor against.\n"
|
||||||
|
" --virtual-connector-strategy Specifies how we should make virtual connectors.\n"
|
||||||
|
" --pipewire-composite-cursor composite the cursor into the PipeWire capture stream (off by default: the node has never carried it, and a consumer that draws its own would get two)\n"
|
||||||
|
+ " --pipewire-composite-external-overlay\n"
|
||||||
|
+ " composite the external overlay layer (mangoapp) into the PipeWire capture stream (off by default, like the cursor)\n"
|
||||||
|
" --hdr-enabled enable HDR output (needs Gamescope WSI layer enabled for support from clients)\n"
|
||||||
|
" If this is not set, and there is a HDR client, it will be tonemapped SDR.\n"
|
||||||
|
" --sdr-gamut-wideness Set the 'wideness' of the gamut for SDR comment. 0 - 1.\n"
|
||||||
|
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||||
|
index 5c65420..0d293c6 100644
|
||||||
|
--- a/src/steamcompmgr.cpp
|
||||||
|
+++ b/src/steamcompmgr.cpp
|
||||||
|
@@ -2323,6 +2323,12 @@ gamescope::ConVar<bool> cv_pipewire_composite_cursor{ "pipewire_composite_cursor
|
||||||
|
"default: the node has never carried the pointer, and a consumer that draws its own would get "
|
||||||
|
"two." };
|
||||||
|
|
||||||
|
+gamescope::ConVar<bool> cv_pipewire_composite_external_overlay{ "pipewire_composite_external_overlay", false,
|
||||||
|
+ "Composite the external overlay layer (mangoapp — the performance overlay) into the PipeWire "
|
||||||
|
+ "capture stream (--pipewire-composite-external-overlay). Off by default, like the cursor: the "
|
||||||
|
+ "node has never carried it, and a consumer showing the stream to the same person already "
|
||||||
|
+ "looking at the screen would get two." };
|
||||||
|
+
|
||||||
|
static void paint_pipewire()
|
||||||
|
{
|
||||||
|
static struct pipewire_buffer *s_pPipewireBuffer = nullptr;
|
||||||
|
@@ -2440,14 +2446,31 @@ static void paint_pipewire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // The external overlay — mangoapp, i.e. the fps/frametime/stats readout. `paint_all` draws
|
||||||
|
+ // it on the scanout composite; this reduced one never referenced it, so a consumer whose
|
||||||
|
+ // ONLY view of the session is this node could not see the overlay the user had turned on.
|
||||||
|
+ // Gated on the focus-appid for the same reason the Steam overlay above is: a consumer that
|
||||||
|
+ // asked for one specific app is asking for that app, not for the session's chrome.
|
||||||
|
+ static uint64_t s_ulLastExternalOverlayCommitId = 0;
|
||||||
|
+ steamcompmgr_win_t *pExternalOverlay = nullptr;
|
||||||
|
+ if ( cv_pipewire_composite_external_overlay && !ulFocusAppId &&
|
||||||
|
+ pFocus->externalOverlayWindow && pFocus->externalOverlayWindow->opacity )
|
||||||
|
+ pExternalOverlay = pFocus->externalOverlayWindow;
|
||||||
|
+ // Its commit id has to join the repaint test below, or the overlay would freeze at whatever
|
||||||
|
+ // it read when the game last presented — the numbers on it change every frame precisely
|
||||||
|
+ // WHILE the picture behind them is static, which is the case the test would otherwise skip.
|
||||||
|
+ const uint64_t ulExternalOverlayCommitId = window_last_done_commit_id( pExternalOverlay );
|
||||||
|
+
|
||||||
|
if ( ulFocusCommitId == s_ulLastFocusCommitId &&
|
||||||
|
ulOverrideCommitId == s_ulLastOverrideCommitId &&
|
||||||
|
+ ulExternalOverlayCommitId == s_ulLastExternalOverlayCommitId &&
|
||||||
|
bDrawCursor == s_bLastCursorDrawn &&
|
||||||
|
nCursorX == s_nLastCursorX && nCursorY == s_nLastCursorY )
|
||||||
|
return;
|
||||||
|
|
||||||
|
s_ulLastFocusCommitId = ulFocusCommitId;
|
||||||
|
s_ulLastOverrideCommitId = ulOverrideCommitId;
|
||||||
|
+ s_ulLastExternalOverlayCommitId = ulExternalOverlayCommitId;
|
||||||
|
s_bLastCursorDrawn = bDrawCursor;
|
||||||
|
s_nLastCursorX = nCursorX;
|
||||||
|
s_nLastCursorY = nCursorY;
|
||||||
|
@@ -2475,6 +2498,16 @@ static void paint_pipewire()
|
||||||
|
( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Deliberately WITHOUT `NoScale`, which is what the scanout composite uses: there the
|
||||||
|
+ // overlay is already sized to the output, while here `currentOutputWidth/Height` are the
|
||||||
|
+ // capture size, and a stream captured at anything other than the session's own resolution
|
||||||
|
+ // would otherwise get the overlay at the wrong size in the corner.
|
||||||
|
+ if ( pExternalOverlay )
|
||||||
|
+ {
|
||||||
|
+ paint_window( pExternalOverlay, pExternalOverlay, &frameInfo, nullptr, PaintWindowFlag::NoFilter |
|
||||||
|
+ ( cv_overlay_unmultiplied_alpha ? PaintWindowFlag::CoverageMode : 0 ) );
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// The cursor, when this stream was asked for it. gamescope keeps the pointer OUT of the
|
||||||
|
// PipeWire node by default — it lives on a hardware plane for scanout, and a remote-play
|
||||||
|
// consumer that draws its own would end up with two — so a consumer that has no cursor of
|
||||||
|
@@ -8457,6 +8490,12 @@ steamcompmgr_main(int argc, char **argv)
|
||||||
|
cv_pipewire_composite_cursor = true;
|
||||||
|
#else
|
||||||
|
fprintf( stderr, "gamescope: --pipewire-composite-cursor ignored (built without PipeWire)\n" );
|
||||||
|
+#endif
|
||||||
|
+ } else if (strcmp(opt_name, "pipewire-composite-external-overlay") == 0) {
|
||||||
|
+#if HAVE_PIPEWIRE
|
||||||
|
+ cv_pipewire_composite_external_overlay = true;
|
||||||
|
+#else
|
||||||
|
+ fprintf( stderr, "gamescope: --pipewire-composite-external-overlay ignored (built without PipeWire)\n" );
|
||||||
|
#endif
|
||||||
|
} else if (strcmp(opt_name, "hdr-enabled") == 0 || strcmp(opt_name, "hdr-enable") == 0) {
|
||||||
|
cv_hdr_enabled = true;
|
||||||
+14
-10
@@ -1,6 +1,6 @@
|
|||||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||||
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||||
Date: Tue, 28 Jul 2026 15:42:01 +0200
|
Date: Sat, 8 Aug 2026 13:17:37 +0000
|
||||||
Subject: [PATCH] punktfunk: stamp the version banner with +pfhdrN
|
Subject: [PATCH] punktfunk: stamp the version banner with +pfhdrN
|
||||||
MIME-Version: 1.0
|
MIME-Version: 1.0
|
||||||
Content-Type: text/plain; charset=UTF-8
|
Content-Type: text/plain; charset=UTF-8
|
||||||
@@ -8,25 +8,27 @@ Content-Transfer-Encoding: 8bit
|
|||||||
|
|
||||||
punktfunk decides a session's shape before the virtual display exists — the
|
punktfunk decides a session's shape before the virtual display exists — the
|
||||||
bit depth in the Welcome (irrevocable; a PQ stream on an 8-bit encoder is a
|
bit depth in the Welcome (irrevocable; a PQ stream on an 8-bit encoder is a
|
||||||
hard error), and whether it must composite the cursor host-side before the
|
hard error), whether it must composite the cursor host-side before the encoder
|
||||||
encoder is even opened. Both answers therefore have to be static properties of
|
is even opened, and which flags the spawn has to carry. All of those have to be
|
||||||
the resolved binary rather than something negotiated later.
|
static properties of the resolved binary rather than something negotiated later.
|
||||||
|
|
||||||
The number is a monotonic patch-set revision, so one probe answers both:
|
The number is a monotonic patch-set revision, so one probe answers all of them:
|
||||||
+pfhdr1 10-bit BT.2020/PQ capture formats
|
+pfhdr1 10-bit BT.2020/PQ capture formats
|
||||||
+pfhdr2 …and --pipewire-composite-cursor
|
+pfhdr2 …and --pipewire-composite-cursor
|
||||||
|
+pfhdr3 …and the headless connector advertises its mode + --custom-refresh-rates
|
||||||
|
+pfhdr4 …and --pipewire-composite-external-overlay
|
||||||
|
|
||||||
NOT for upstream: drop this once the functional patches land there and plain
|
NOT for upstream: drop this once the functional patches land there and plain
|
||||||
version floors answer the same questions.
|
version floors answer the same questions.
|
||||||
---
|
---
|
||||||
src/meson.build | 7 ++++++-
|
src/meson.build | 9 ++++++++-
|
||||||
1 file changed, 6 insertions(+), 1 deletion(-)
|
1 file changed, 8 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
diff --git a/src/meson.build b/src/meson.build
|
diff --git a/src/meson.build b/src/meson.build
|
||||||
index 662f752..af48d01 100644
|
index 662f752..12fd38a 100644
|
||||||
--- a/src/meson.build
|
--- a/src/meson.build
|
||||||
+++ b/src/meson.build
|
+++ b/src/meson.build
|
||||||
@@ -177,7 +177,12 @@ compiler_version = cc.version()
|
@@ -177,7 +177,14 @@ compiler_version = cc.version()
|
||||||
|
|
||||||
vcs_tag_cmd = ['git', 'describe', '--always', '--tags', '--dirty=+']
|
vcs_tag_cmd = ['git', 'describe', '--always', '--tags', '--dirty=+']
|
||||||
vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
|
vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
|
||||||
@@ -36,7 +38,9 @@ index 662f752..af48d01 100644
|
|||||||
+# punktfunk/1 Welcome is irrevocable). The number is a monotonic PATCH-SET revision:
|
+# punktfunk/1 Welcome is irrevocable). The number is a monotonic PATCH-SET revision:
|
||||||
+# +pfhdr1 — 10-bit BT.2020/PQ capture formats on the PipeWire node
|
+# +pfhdr1 — 10-bit BT.2020/PQ capture formats on the PipeWire node
|
||||||
+# +pfhdr2 — …and `--pipewire-composite-cursor`
|
+# +pfhdr2 — …and `--pipewire-composite-cursor`
|
||||||
+version_tag = vcs_tag + '+pfhdr2' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
+# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates`
|
||||||
|
+# +pfhdr4 — …and `--pipewire-composite-external-overlay`
|
||||||
|
+version_tag = vcs_tag + '+pfhdr4' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||||
|
|
||||||
gamescope_version_conf = configuration_data()
|
gamescope_version_conf = configuration_data()
|
||||||
gamescope_version_conf.set('VCS_TAG', version_tag)
|
gamescope_version_conf.set('VCS_TAG', version_tag)
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
From 509fb928c7dc3307372629ca692f4c895c4fe984 Mon Sep 17 00:00:00 2001
|
||||||
|
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
|
||||||
|
Date: Sat, 8 Aug 2026 19:17:25 +0200
|
||||||
|
Subject: [PATCH] punktfunk: never destroy the Vulkan device or output at exit
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
|
||||||
|
Every gamescope session punktfunk spawns ended in SIGSEGV. It happened after
|
||||||
|
the compositor had already done its work — "Primary child shut down!", then a
|
||||||
|
coredump — so the stream itself looked fine and the crash only showed up as a
|
||||||
|
steady drip of coredumps and a non-zero exit from the spawn.
|
||||||
|
|
||||||
|
The cause is static destruction order, not anything gamescope does wrong at
|
||||||
|
runtime. `g_device` (CVulkanDevice) and `g_output` (VulkanOutput_t) were plain
|
||||||
|
globals, so glibc ran their destructors from `__run_exit_handlers` once main()
|
||||||
|
returned. Those destructors call back into the driver:
|
||||||
|
|
||||||
|
~CVulkanCmdBuffer -> m_device->vk.FreeCommandBuffers(...)
|
||||||
|
~CVulkanTexture -> vk.Destroy*(...)
|
||||||
|
|
||||||
|
but the Vulkan ICD has already been torn down and unloaded by that point, so
|
||||||
|
each call jumps through a function pointer into an unmapped page. The faulting
|
||||||
|
address equals the instruction pointer, which is the signature of exactly that:
|
||||||
|
|
||||||
|
#0 0x00007fe8fd1d1070 in ?? ()
|
||||||
|
#1 CVulkanCmdBuffer::~CVulkanCmdBuffer at rendervulkan.cpp:1543
|
||||||
|
#9 std::vector<unique_ptr<CVulkanCmdBuffer>>::~vector (g_device+1792)
|
||||||
|
#10 CVulkanDevice::~CVulkanDevice at rendervulkan.hpp:768
|
||||||
|
#11 __run_exit_handlers / exit()
|
||||||
|
|
||||||
|
On NVIDIA it is 100% reproducible:
|
||||||
|
`gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true`
|
||||||
|
exits 139 every time, and cleanly with this patch (5/5, plus 2/2 at the real
|
||||||
|
session's 2752x2064@120 --steam).
|
||||||
|
|
||||||
|
Nothing needs freeing at that point. The process is exiting; the kernel
|
||||||
|
reclaims the device, its command buffers and every GPU allocation. So give both
|
||||||
|
objects storage that is constructed exactly as before but never destroyed — a
|
||||||
|
union member is destroyed only if the union's destructor says so, and ours
|
||||||
|
deliberately does not. `g_device` and `g_output` keep their names and types
|
||||||
|
(now references bound at constant-initialisation time), so no use site changes.
|
||||||
|
|
||||||
|
Both are needed: pinning only the device relocated the fault into
|
||||||
|
~VulkanOutput_t, which is why this is a shared helper and not a one-off.
|
||||||
|
---
|
||||||
|
src/rendervulkan.cpp | 35 +++++++++++++++++++++++++++++++++--
|
||||||
|
src/rendervulkan.hpp | 4 ++--
|
||||||
|
2 files changed, 35 insertions(+), 4 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp
|
||||||
|
index 5c2dd11..8cd5ca2 100644
|
||||||
|
--- a/src/rendervulkan.cpp
|
||||||
|
+++ b/src/rendervulkan.cpp
|
||||||
|
@@ -118,7 +118,37 @@ static VkResult vulkan_load_module()
|
||||||
|
return s_result;
|
||||||
|
}
|
||||||
|
|
||||||
|
-VulkanOutput_t g_output;
|
||||||
|
+// punktfunk: globals that own GPU objects must OUTLIVE static destruction.
|
||||||
|
+//
|
||||||
|
+// gamescope kept its Vulkan device and output as plain globals, so glibc ran their destructors
|
||||||
|
+// from `__run_exit_handlers` after main() returned. Those destructors call back into the driver
|
||||||
|
+// (~CVulkanCmdBuffer -> vk.FreeCommandBuffers, ~CVulkanTexture -> vk.Destroy*), but by then the
|
||||||
|
+// Vulkan ICD has already been torn down and unloaded, so the call jumps through a function
|
||||||
|
+// pointer into an unmapped page: SIGSEGV at exactly the address it tried to execute. On NVIDIA it
|
||||||
|
+// is 100% reproducible -- `gamescope --backend headless ... -- true` dies with exit 139 EVERY
|
||||||
|
+// time -- so every punktfunk gamescope session ended in a coredump.
|
||||||
|
+//
|
||||||
|
+// Nothing needs freeing at that point: the process is exiting and the kernel reclaims the device,
|
||||||
|
+// its command buffers and every GPU allocation. So give these objects storage that is constructed
|
||||||
|
+// exactly as before but NEVER destroyed. A union member is only destroyed if the union says so,
|
||||||
|
+// and ours deliberately does not.
|
||||||
|
+//
|
||||||
|
+// Fixing only one of them just moves the crash to the next global (verified: pinning the device
|
||||||
|
+// relocated the fault into ~VulkanOutput_t), which is why this is a shared helper rather than a
|
||||||
|
+// one-off.
|
||||||
|
+namespace
|
||||||
|
+{
|
||||||
|
+ template <typename T>
|
||||||
|
+ union CNoDestroy
|
||||||
|
+ {
|
||||||
|
+ T value;
|
||||||
|
+ CNoDestroy() : value() {}
|
||||||
|
+ ~CNoDestroy() {} // deliberately does NOT destroy `value`
|
||||||
|
+ };
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+namespace { CNoDestroy<VulkanOutput_t> g_outputHolder; }
|
||||||
|
+VulkanOutput_t &g_output = g_outputHolder.value;
|
||||||
|
|
||||||
|
uint32_t g_uCompositeDebug = 0u;
|
||||||
|
gamescope::ConVar<uint32_t> cv_composite_debug{ "composite_debug", 0, "Debug composition flags" };
|
||||||
|
@@ -1943,7 +1973,8 @@ void CVulkanCmdBuffer::insertBarrier(bool flush)
|
||||||
|
0, 0, nullptr, 0, nullptr, barriers.size(), barriers.data());
|
||||||
|
}
|
||||||
|
|
||||||
|
-CVulkanDevice g_device;
|
||||||
|
+namespace { CNoDestroy<CVulkanDevice> g_deviceHolder; }
|
||||||
|
+CVulkanDevice &g_device = g_deviceHolder.value;
|
||||||
|
|
||||||
|
static bool allDMABUFsEqual( wlr_dmabuf_attributes *pDMA )
|
||||||
|
{
|
||||||
|
diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp
|
||||||
|
index b6749d4..a9335c4 100644
|
||||||
|
--- a/src/rendervulkan.hpp
|
||||||
|
+++ b/src/rendervulkan.hpp
|
||||||
|
@@ -564,7 +564,7 @@ enum ShaderType {
|
||||||
|
SHADER_TYPE_COUNT
|
||||||
|
};
|
||||||
|
|
||||||
|
-extern VulkanOutput_t g_output;
|
||||||
|
+extern VulkanOutput_t &g_output;
|
||||||
|
|
||||||
|
struct SamplerState
|
||||||
|
{
|
||||||
|
@@ -1007,4 +1007,4 @@ void vulkan_wait_idle();
|
||||||
|
// Whether the driver implements VK_EXT_physical_device_drm
|
||||||
|
bool vulkan_has_drm_props();
|
||||||
|
|
||||||
|
-extern CVulkanDevice g_device;
|
||||||
|
+extern CVulkanDevice &g_device;
|
||||||
|
--
|
||||||
|
2.55.0
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# punktfunk-gamescope — gamescope carrying punktfunk's capture patches, installed under its own
|
||||||
|
# name so it sits BESIDE the distro's gamescope (Game Mode keeps using that one).
|
||||||
|
#
|
||||||
|
# This spec REPACKS a binary that was already built, rather than building gamescope itself: the
|
||||||
|
# build is a ~10-minute meson compile of an unrelated tree that CI already does once per Fedora
|
||||||
|
# major and caches (.gitea/workflows/rpm.yml). Rebuilding it inside rpmbuild would double that for
|
||||||
|
# no gain, and would need network access in the build root to fetch the upstream tree.
|
||||||
|
#
|
||||||
|
# The counterpart for Arch is packaging/gamescope/PKGBUILD, which DOES build from source, because
|
||||||
|
# makepkg fetches sources by design and the AUR-style recipe is what an Arch user expects.
|
||||||
|
#
|
||||||
|
# Usage: bash packaging/gamescope/build-gamescope-rpm.sh --binary <path-to-punktfunk-gamescope>
|
||||||
|
Name: punktfunk-gamescope
|
||||||
|
Version: %{pf_version}
|
||||||
|
Release: %{pf_release}%{?dist}
|
||||||
|
Summary: gamescope with punktfunk's PipeWire capture patches (HDR, cursor, overlay, virtual-display modes)
|
||||||
|
|
||||||
|
# gamescope is BSD-2-Clause; our patches are contributed under the same terms.
|
||||||
|
License: BSD-2-Clause
|
||||||
|
URL: https://git.unom.io/unom/punktfunk
|
||||||
|
Source0: punktfunk-gamescope
|
||||||
|
|
||||||
|
# Not `Provides: gamescope` and not `Conflicts:` either — this ships a differently-named binary and
|
||||||
|
# is designed to coexist. A box's Game Mode session keeps running the distro's gamescope; only the
|
||||||
|
# sessions punktfunk-host starts itself resolve this one (PUNKTFUNK_GAMESCOPE_BIN >
|
||||||
|
# punktfunk-gamescope > gamescope).
|
||||||
|
#
|
||||||
|
# The runtime library Requires are auto-generated by rpmbuild from the binary's ELF NEEDED entries,
|
||||||
|
# which is exactly right here: this binary is soname-coupled to the Fedora major it was compiled
|
||||||
|
# on, and the generated Requires are what stop it installing on the wrong one.
|
||||||
|
Recommends: punktfunk-host
|
||||||
|
|
||||||
|
# Nothing is compiled here, so there is no debuginfo to extract; without this rpmbuild fails
|
||||||
|
# looking for sources it was never given.
|
||||||
|
%global debug_package %{nil}
|
||||||
|
|
||||||
|
%description
|
||||||
|
gamescope built from the upstream revision punktfunk pins, plus the patches in
|
||||||
|
packaging/gamescope/patches:
|
||||||
|
|
||||||
|
* 10-bit BT.2020/PQ capture formats on the PipeWire node, so an HDR game reaches a capture
|
||||||
|
consumer as HDR instead of pre-tonemapped SDR.
|
||||||
|
* --pipewire-composite-cursor: paint the pointer into the capture stream, so a consumer with no
|
||||||
|
cursor of its own gets one — and the host stops blending one in, which frees the encoder's
|
||||||
|
fastest zero-copy source.
|
||||||
|
* A headless session advertises its real mode and refresh rates (and --custom-refresh-rates), so
|
||||||
|
Steam and games see the resolution and refresh the stream actually runs at instead of an
|
||||||
|
unnamed 60 Hz panel.
|
||||||
|
* --pipewire-composite-external-overlay: paint the mangoapp performance overlay into the capture
|
||||||
|
stream, so the fps/stats readout is visible to someone watching remotely.
|
||||||
|
|
||||||
|
Installed as /usr/bin/punktfunk-gamescope. Your system gamescope is untouched.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
# Nothing to unpack: Source0 IS the binary.
|
||||||
|
|
||||||
|
%build
|
||||||
|
# Nothing to build — see the header.
|
||||||
|
|
||||||
|
%install
|
||||||
|
install -Dm0755 %{SOURCE0} %{buildroot}%{_bindir}/punktfunk-gamescope
|
||||||
|
|
||||||
|
%check
|
||||||
|
# The marker is the host's entire capability probe (`gamescope_patch_level()`): a binary that lost
|
||||||
|
# the patches would install fine and then silently stream SDR with no cursor. Refuse to package it.
|
||||||
|
#
|
||||||
|
# Executed in the build root, which is the same container the binary was compiled in — if that ever
|
||||||
|
# stops being true this check is the thing that notices.
|
||||||
|
%{buildroot}%{_bindir}/punktfunk-gamescope --version 2>&1 | grep -q '+pfhdr' || {
|
||||||
|
echo "punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_bindir}/punktfunk-gamescope
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
# Generated per build; see the git history for the patch set's own changes.
|
||||||
@@ -63,7 +63,7 @@ unwrapped.overrideAttrs (old: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
# nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the
|
# nixpkgs builds from a `fetchFromGitHub` src, so there is no `.git` for `git describe` and the
|
||||||
# banner would read `+pfhdr2 (gcc …)` with no version at all — which the host's diagnostic
|
# banner would read `+pfhdrN (gcc …)` with no version at all — which the host's diagnostic
|
||||||
# version gate then misreads (it takes the first X.Y.Z triple it finds, i.e. the compiler's).
|
# version gate then misreads (it takes the first X.Y.Z triple it finds, i.e. the compiler's).
|
||||||
# Substituting the real version in keeps `--version` honest AND keeps our marker.
|
# Substituting the real version in keeps `--version` honest AND keeps our marker.
|
||||||
postPatch = (old.postPatch or "") + ''
|
postPatch = (old.postPatch or "") + ''
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in
|
# NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in
|
||||||
# their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh):
|
# their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh):
|
||||||
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
|
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
|
||||||
# UDP socket-buffer sysctls, the firewall openers, the `input`-group membership for virtual
|
# UDP socket-buffer sysctls, the firewall openers, the `input`- and `punktfunk`-group membership
|
||||||
# gamepads, the management web console (`services.punktfunk.web`, on by default with the host — the
|
# for virtual gamepads, the management web console (`services.punktfunk.web`, on by default with
|
||||||
# RPM/deb Recommends), and the opt-in plugin/script runner (`services.punktfunk.scripting`).
|
# the host — the RPM/deb Recommends), and the opt-in plugin/script runner
|
||||||
|
# (`services.punktfunk.scripting`).
|
||||||
#
|
#
|
||||||
# Usage (flake):
|
# Usage (flake):
|
||||||
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
|
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
|
||||||
@@ -111,9 +112,11 @@ in
|
|||||||
default = [ ];
|
default = [ ];
|
||||||
example = [ "alice" ];
|
example = [ "alice" ];
|
||||||
description = ''
|
description = ''
|
||||||
Users to add to the `input` group — required for the virtual gamepads the host creates
|
Users to add to the `input` and `punktfunk` groups — required for the virtual gamepads
|
||||||
(`/dev/uinput`, `/dev/uhid`, and the usbip/vhci virtual Steam Deck). The host runs as
|
the host creates: `input` covers `/dev/uinput` and `/dev/uhid`, `punktfunk` covers the
|
||||||
these users' `systemd --user` service.
|
usbip/vhci nodes the virtual Steam Deck pad attaches through. The second is separate on
|
||||||
|
purpose — it can emulate arbitrary USB hardware, so only list users you would trust with
|
||||||
|
that. The host runs as these users' `systemd --user` service.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,9 +329,22 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
# `input` group membership for the virtual-gamepad nodes (mirrors the RPM's usermod hint).
|
# `input` group membership for the virtual-gamepad nodes (mirrors the RPM's usermod hint).
|
||||||
|
#
|
||||||
|
# `punktfunk` is the SECOND group 60-punktfunk.rules needs: it owns the usbip vhci
|
||||||
|
# attach/detach nodes, and is deliberately not `input` because writing `attach` materialises
|
||||||
|
# an arbitrary emulated USB device — a root-only kernel primitive that must not ride on the
|
||||||
|
# group every gamepad guide tells you to join (security-review 2026-08-05 M-4). Declaring the
|
||||||
|
# group is not optional: the rule shells out to `chgrp punktfunk`, which fails outright if
|
||||||
|
# nothing ever created it, leaving the nodes root-only and the virtual Steam Deck pad unable
|
||||||
|
# to attach. Membership follows `host.users`, which is already the explicit "these users run
|
||||||
|
# the host" list this option's description scopes to the usbip/vhci pad.
|
||||||
users.groups.input = { };
|
users.groups.input = { };
|
||||||
|
users.groups.punktfunk = { };
|
||||||
users.users = genAttrs cfg.host.users (_: {
|
users.users = genAttrs cfg.host.users (_: {
|
||||||
extraGroups = [ "input" ];
|
extraGroups = [
|
||||||
|
"input"
|
||||||
|
"punktfunk"
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
# Status-tray autostart entry (self-gating: `--autostart` exits unless this user runs a host).
|
# Status-tray autostart entry (self-gating: `--autostart` exits unless this user runs a host).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/plugin-kit",
|
"name": "@punktfunk/plugin-kit",
|
||||||
"version": "0.3.3",
|
"version": "0.4.0",
|
||||||
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
"description": "Effect-based framework for punktfunk plugins: lifecycle runtime, config/state, sync engine, UI serving, CLI scaffold, and browser helpers.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
|
|||||||
@@ -80,4 +80,18 @@ export class UiServeError extends Data.TaggedError("UiServeError")<{
|
|||||||
export class SyncError extends Data.TaggedError("SyncError")<{
|
export class SyncError extends Data.TaggedError("SyncError")<{
|
||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
readonly cause: unknown;
|
readonly cause: unknown;
|
||||||
}> {}
|
}> {
|
||||||
|
/**
|
||||||
|
* Same load-bearing getter as {@link HostRequestError}, and for the same reason one step further
|
||||||
|
* out: without it `String(e)` is the bare tag `SyncError`, so a plugin that renders its sync
|
||||||
|
* failure into an API error or a toast shows the operator a word instead of the refusal.
|
||||||
|
*
|
||||||
|
* That is how a rom-manager sync refused with a fully explanatory 403 reached its own UI as
|
||||||
|
* "Decode error" and nothing else — the reason existed at every layer and was dropped at this
|
||||||
|
* one. `describeCause` unwraps a nested `HostRequestError` through its own message getter, so
|
||||||
|
* the host's sentence survives the whole way to the surface.
|
||||||
|
*/
|
||||||
|
override get message(): string {
|
||||||
|
return `sync (${this.reason}) failed: ${describeCause(this.cause)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export {
|
|||||||
deriveConfigJsonSchema,
|
deriveConfigJsonSchema,
|
||||||
httpApiEnv,
|
httpApiEnv,
|
||||||
makeConfigHandler,
|
makeConfigHandler,
|
||||||
|
makeLaunchHandler,
|
||||||
|
type PluginLaunchTarget,
|
||||||
type ServeUiConfig,
|
type ServeUiConfig,
|
||||||
type ServeUiOptions,
|
type ServeUiOptions,
|
||||||
serveUi,
|
serveUi,
|
||||||
|
|||||||
@@ -116,6 +116,79 @@ export const makeConfigHandler = <S extends Schema.Top>(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** What a plugin answers when the host asks how to start one of its own library entries. */
|
||||||
|
export interface PluginLaunchTarget {
|
||||||
|
/**
|
||||||
|
* The command LINE to run. The plugin composes AND quotes it — the host runs it as-is, so
|
||||||
|
* anything interpolated from untrusted input (a ROM filename) must already be quoted here.
|
||||||
|
*/
|
||||||
|
readonly command: string;
|
||||||
|
/** Absolute working directory, for a program that resolves cores or configs relative to one. */
|
||||||
|
readonly cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `/__launch` request handler, split out so it can be driven directly in tests — the wire shape
|
||||||
|
* is a contract with the HOST, which deserves a real round-trip test rather than a mock.
|
||||||
|
*
|
||||||
|
* This is the plugin half of the `plugin` launch kind. A library entry published with
|
||||||
|
* `launch: {kind: "plugin", value: "<key>"}` carries no command; when a client picks that tile, the
|
||||||
|
* host asks the plugin that owns it — over this route, on the plugin's loopback UI port, with the
|
||||||
|
* per-boot secret — what to run, and runs the answer itself (only the host can put the process
|
||||||
|
* inside the captured session, and it needs the child to know when the game exits).
|
||||||
|
*
|
||||||
|
* **Answering `null` is load-bearing.** It becomes a 404, which is what the host gets for an entry
|
||||||
|
* this plugin never published — and therefore what makes a library entry forged by someone holding a
|
||||||
|
* stolen plugin token inert rather than arbitrary command execution. Resolve against your own state,
|
||||||
|
* never by trusting the key.
|
||||||
|
*/
|
||||||
|
export const makeLaunchHandler = (
|
||||||
|
resolve: (entry: string) => Effect.Effect<PluginLaunchTarget | null>,
|
||||||
|
): ((req: Request) => Promise<Response>) => {
|
||||||
|
return async (req: Request): Promise<Response> => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("method not allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch (cause) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "body must be JSON", issue: String(cause) },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const entry = (body as { entry?: unknown } | null)?.entry;
|
||||||
|
if (typeof entry !== "string" || entry.length === 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "body must be {entry: string}" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let target: PluginLaunchTarget | null;
|
||||||
|
try {
|
||||||
|
target = await Effect.runPromise(resolve(entry));
|
||||||
|
} catch (cause) {
|
||||||
|
// A resolver that died is not the same as one that disowned the entry: keep 404 meaning
|
||||||
|
// "not mine" so the host's log says which of the two happened.
|
||||||
|
return Response.json(
|
||||||
|
{ error: "launch resolution failed", issue: String(cause) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (target === null) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `no launchable entry "${entry}"` },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
command: target.command,
|
||||||
|
...(target.cwd !== undefined ? { cwd: target.cwd } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface ServeUiOptions {
|
export interface ServeUiOptions {
|
||||||
/** Console nav title. */
|
/** Console nav title. */
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
@@ -143,6 +216,19 @@ export interface ServeUiOptions {
|
|||||||
* `/plugin-ui/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
|
* `/plugin-ui/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
|
||||||
*/
|
*/
|
||||||
readonly config?: ServeUiConfig<Schema.Top>;
|
readonly config?: ServeUiConfig<Schema.Top>;
|
||||||
|
/**
|
||||||
|
* Serve `POST /__launch` — how a plugin answers "what do I run for this entry?" for library
|
||||||
|
* entries it published with `launch: {kind: "plugin", value: "<key>"}`.
|
||||||
|
*
|
||||||
|
* Set this when the plugin's tiles start something the host cannot name on its own (a ROM through
|
||||||
|
* an emulator, say). The alternative — publishing `kind: "command"` — is refused from the plugin
|
||||||
|
* lane outright: a stored command line is executed as the host user, and only the operator's own
|
||||||
|
* token may write one.
|
||||||
|
*
|
||||||
|
* Resolve against the plugin's OWN state and answer `null` for anything else; see
|
||||||
|
* {@link makeLaunchHandler} for why that 404 is the security-relevant case.
|
||||||
|
*/
|
||||||
|
readonly launch?: (entry: string) => Effect.Effect<PluginLaunchTarget | null>;
|
||||||
/**
|
/**
|
||||||
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
|
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
|
||||||
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
|
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
|
||||||
@@ -183,6 +269,9 @@ export const serveUi = (
|
|||||||
const serveConfig = opts.config
|
const serveConfig = opts.config
|
||||||
? makeConfigHandler(opts.config)
|
? makeConfigHandler(opts.config)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const serveLaunch = opts.launch
|
||||||
|
? makeLaunchHandler(opts.launch)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const fetch = async (req: Request): Promise<Response | undefined> => {
|
const fetch = async (req: Request): Promise<Response | undefined> => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -192,6 +281,9 @@ export const serveUi = (
|
|||||||
if (url.pathname === "/__config") {
|
if (url.pathname === "/__config") {
|
||||||
return serveConfig?.(req) ?? new Response("not found", { status: 404 });
|
return serveConfig?.(req) ?? new Response("not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/__launch") {
|
||||||
|
return serveLaunch?.(req) ?? new Response("not found", { status: 404 });
|
||||||
|
}
|
||||||
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
|
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
|
||||||
return handler(req);
|
return handler(req);
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-1
@@ -16,7 +16,9 @@ export type Artwork = typeof Artwork.Type;
|
|||||||
* How the host should launch a title. **The host owns this vocabulary** — it validates the value
|
* How the host should launch a title. **The host owns this vocabulary** — it validates the value
|
||||||
* per kind and builds the actual URI / command line itself, so a plugin only ever supplies a
|
* per kind and builds the actual URI / command line itself, so a plugin only ever supplies a
|
||||||
* validated value, never a command. That is the security invariant behind the whole provider lane:
|
* validated value, never a command. That is the security invariant behind the whole provider lane:
|
||||||
* a client sends an entry id, and the host resolves what to run.
|
* a client sends an entry id, and the host resolves what to run. (`plugin`, below, is the one kind
|
||||||
|
* whose command the plugin composes — but it is still never *stored*: the host asks the live plugin
|
||||||
|
* at launch time, so an entry on its own executes nothing.)
|
||||||
*
|
*
|
||||||
* `kind` is a plain string rather than a union so the kit never has to ship a release to keep up
|
* `kind` is a plain string rather than a union so the kit never has to ship a release to keep up
|
||||||
* with a host that grew a new kind. The kinds the host understands today:
|
* with a host that grew a new kind. The kinds the host understands today:
|
||||||
@@ -32,6 +34,13 @@ export type Artwork = typeof Artwork.Type;
|
|||||||
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
|
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
|
||||||
* | `gog` | `exe \t args \t workdir` | windows |
|
* | `gog` | `exe \t args \t workdir` | windows |
|
||||||
* | `aumid` | `<PFN>!<AppId>` | windows |
|
* | `aumid` | `<PFN>!<AppId>` | windows |
|
||||||
|
* | `plugin` | an opaque key in THIS plugin's namespace — see below | both |
|
||||||
|
*
|
||||||
|
* `plugin` is the escape hatch for a tile the host cannot name on its own (a ROM through whichever
|
||||||
|
* emulator the operator configured). The value is meaningless to the host: it hands the key back to
|
||||||
|
* the plugin that published the entry, on its own loopback UI port, and runs the command line that
|
||||||
|
* comes back. Serve it with `serveUi({launch})`; a plugin that publishes this kind without serving
|
||||||
|
* `/__launch` grows unlaunchable tiles.
|
||||||
*
|
*
|
||||||
* An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a
|
* An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a
|
||||||
* plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile.
|
* plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// What a kit error says when something interpolates it — which is the whole diagnosis surface a
|
// What a kit error says when something interpolates it — which is the whole diagnosis surface a
|
||||||
// plugin operator gets, because `sync-engine`'s failure path logs `${e.cause}` and nothing else.
|
// plugin operator gets, because `sync-engine`'s failure path logs `${e.cause}` and nothing else.
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { HostRequestError } from "../src/errors.js";
|
import { HostRequestError, SyncError } from "../src/errors.js";
|
||||||
|
|
||||||
describe("HostRequestError", () => {
|
describe("HostRequestError", () => {
|
||||||
// Regression for 2026-08-08: this printed the bare tag, so `plugin:lutris sync (startup)
|
// Regression for 2026-08-08: this printed the bare tag, so `plugin:lutris sync (startup)
|
||||||
@@ -62,3 +62,36 @@ describe("HostRequestError", () => {
|
|||||||
expect(err.path).toBe("/library/provider/heroic");
|
expect(err.path).toBe("/library/provider/heroic");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SyncError", () => {
|
||||||
|
// Regression for 2026-08-08 (rom-manager): the host refused every ROM reconcile with a 403 that
|
||||||
|
// named the offending field AND the fix, `HostRequestError` carried that sentence faithfully —
|
||||||
|
// and then this class dropped it, because the default string form is the bare tag. The plugin
|
||||||
|
// rendered `String(e)` into its API error, so the operator's entire diagnosis was the word
|
||||||
|
// "SyncError" (and, after the undecodable 500, "Decode error"). The chain must survive.
|
||||||
|
test("carries the nested host explanation, not the bare tag", () => {
|
||||||
|
const err = new SyncError({
|
||||||
|
reason: "manual",
|
||||||
|
cause: new HostRequestError({
|
||||||
|
method: "PUT",
|
||||||
|
path: "/library/provider/rom-manager",
|
||||||
|
cause: {
|
||||||
|
error:
|
||||||
|
'`launch.kind = "command"` is executed as the host user and may only be set with the operator\'s admin token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(`${err}`).toContain("manual");
|
||||||
|
expect(`${err}`).toContain("/library/provider/rom-manager");
|
||||||
|
expect(`${err}`).toContain("launch.kind");
|
||||||
|
expect(`${err}`).not.toBe("SyncError");
|
||||||
|
expect(`${err}`).not.toContain("[object Object]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps its tag and fields for catchTag narrowing", () => {
|
||||||
|
const err = new SyncError({ reason: "startup", cause: "boom" });
|
||||||
|
expect(err._tag).toBe("SyncError");
|
||||||
|
expect(err.reason).toBe("startup");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// The `/__launch` wire shape — the plugin half of the `plugin` launch kind, and a contract with the
|
||||||
|
// HOST (`library::ask_plugin_launch`), so it is driven end to end here rather than mocked.
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
import { makeLaunchHandler, type PluginLaunchTarget } from "../src/index.js";
|
||||||
|
|
||||||
|
const post = (body: unknown, init?: RequestInit): Request =>
|
||||||
|
new Request("http://127.0.0.1/__launch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A resolver that owns exactly one entry — the shape every real plugin's resolver has. */
|
||||||
|
const oneEntry = (key: string, target: PluginLaunchTarget) =>
|
||||||
|
makeLaunchHandler((entry) => Effect.succeed(entry === key ? target : null));
|
||||||
|
|
||||||
|
describe("makeLaunchHandler", () => {
|
||||||
|
test("answers a known entry with its command", async () => {
|
||||||
|
const h = oneEntry("snes/smw.sfc", {
|
||||||
|
command: "retroarch -L snes9x.so '/roms/snes/smw.sfc'",
|
||||||
|
});
|
||||||
|
const res = await h(post({ entry: "snes/smw.sfc" }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
command: "retroarch -L snes9x.so '/roms/snes/smw.sfc'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("carries a working directory only when the plugin set one", async () => {
|
||||||
|
const withCwd = oneEntry("k", { command: "run", cwd: "/opt/emu" });
|
||||||
|
expect(await (await withCwd(post({ entry: "k" }))).json()).toEqual({
|
||||||
|
command: "run",
|
||||||
|
cwd: "/opt/emu",
|
||||||
|
});
|
||||||
|
const without = oneEntry("k", { command: "run" });
|
||||||
|
// Absent, not `cwd: undefined` — the host decodes {command, cwd?} and a present-but-null key
|
||||||
|
// is the exact shape that broke this plugin's own API once before (v0.3.2).
|
||||||
|
expect(
|
||||||
|
Object.hasOwn(await (await without(post({ entry: "k" }))).json(), "cwd"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("404s an entry the plugin does not own — the forged-entry case", async () => {
|
||||||
|
const h = oneEntry("mine", { command: "run" });
|
||||||
|
const res = await h(post({ entry: "someone-elses" }));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a resolver that dies is a 500, distinct from disowning the entry", async () => {
|
||||||
|
const h = makeLaunchHandler(
|
||||||
|
() => Effect.die(new Error("cache unreadable")) as Effect.Effect<null>,
|
||||||
|
);
|
||||||
|
const res = await h(post({ entry: "k" }));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a body that is not {entry: string}", async () => {
|
||||||
|
const h = oneEntry("k", { command: "run" });
|
||||||
|
expect((await h(post("not json at all"))).status).toBe(400);
|
||||||
|
expect((await h(post({}))).status).toBe(400);
|
||||||
|
expect((await h(post({ entry: 42 }))).status).toBe(400);
|
||||||
|
expect((await h(post({ entry: "" }))).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only POST", async () => {
|
||||||
|
const h = oneEntry("k", { command: "run" });
|
||||||
|
const res = await h(
|
||||||
|
new Request("http://127.0.0.1/__launch", { method: "GET" }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(405);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,7 +85,13 @@ default `pf2`), `PUNKTFUNK_MGMT_PORT` (47990), `PUNKTFUNK_WEB_PORT` (47992).
|
|||||||
- **System tuning (sudo):** `/etc/sysctl.d/99-punktfunk-net.conf` (32 MB UDP buffers — the #1
|
- **System tuning (sudo):** `/etc/sysctl.d/99-punktfunk-net.conf` (32 MB UDP buffers — the #1
|
||||||
high-bitrate lever), `/etc/udev/rules.d/60-punktfunk.rules` (`uinput`/`uhid` access),
|
high-bitrate lever), `/etc/udev/rules.d/60-punktfunk.rules` (`uinput`/`uhid` access),
|
||||||
`/etc/modules-load.d/punktfunk.conf` (`vhci-hcd` for the native Deck pad), `$USER` in the `input`
|
`/etc/modules-load.d/punktfunk.conf` (`vhci-hcd` for the native Deck pad), `$USER` in the `input`
|
||||||
group — and `/etc/atomic-update.conf.d/punktfunk.conf`, which registers the three files on
|
group **and in `punktfunk`** — the latter created here if missing, because the udev rule
|
||||||
|
`chgrp`s the vhci `attach`/`detach` nodes to it and a rule that names a nonexistent group fails
|
||||||
|
silently, leaving the native Deck pad unable to attach (the deb/rpm/arch scriptlets `groupadd` it;
|
||||||
|
nothing on this path did until now). It is separate from `input` on purpose: writing `attach`
|
||||||
|
materialises an arbitrary emulated USB device (security-review 2026-08-05 M-4). Drop it with
|
||||||
|
`sudo gpasswd -d "$USER" punktfunk` if you would rather stream without that pad.
|
||||||
|
Plus `/etc/atomic-update.conf.d/punktfunk.conf`, which registers the three files on
|
||||||
SteamOS's atomic-update keep list so A/B OS updates carry them over (verified: without it an
|
SteamOS's atomic-update keep list so A/B OS updates carry them over (verified: without it an
|
||||||
update silently strips them — pads degrade to Xbox 360, buffers drop to 208 KB).
|
update silently strips them — pads degrade to Xbox 360, buffers drop to 208 KB).
|
||||||
|
|
||||||
@@ -104,8 +110,13 @@ host advertises over mDNS as `_punktfunk._udp`, so clients discover it automatic
|
|||||||
|
|
||||||
- **distrobox required.** If missing: `curl -sfL https://raw.githubusercontent.com/89luca89/distrobox/main/install | sh -s -- --prefix ~/.local` (then ensure `~/.local/bin` is on PATH).
|
- **distrobox required.** If missing: `curl -sfL https://raw.githubusercontent.com/89luca89/distrobox/main/install | sh -s -- --prefix ~/.local` (then ensure `~/.local/bin` is on PATH).
|
||||||
- **First build is slow** (~10–15 min + ~1 GB toolchain/image). Incremental afterwards.
|
- **First build is slow** (~10–15 min + ~1 GB toolchain/image). Incremental afterwards.
|
||||||
- **No passwordless sudo** → the installer skips the sysctl/udev/input steps with a warning; high
|
- **No passwordless sudo** → the installer skips the sysctl/udev/group steps with a warning; high
|
||||||
bitrates will drop packets until you apply `99-punktfunk-net.conf` and join `input` yourself.
|
bitrates will drop packets until you apply `99-punktfunk-net.conf` and join `input` (and
|
||||||
|
`punktfunk`, for the native Deck pad) yourself. The script prints the exact commands.
|
||||||
|
- **Installed before 0.25.0?** `web.env` was written at the ambient umask, i.e. world-readable, so
|
||||||
|
the console password and session secret leaked to every local account. `install.sh`/`update.sh`
|
||||||
|
now tighten `~/.config/punktfunk` to `0700` and `web.env` to `0600` on every run and say so —
|
||||||
|
but rotate `PUNKTFUNK_UI_PASSWORD` afterwards, because a chmod does not un-leak a read secret.
|
||||||
- **Game Mode auto-suspend** drops the host off the network on idle — disable it (Settings → Power)
|
- **Game Mode auto-suspend** drops the host off the network on idle — disable it (Settings → Power)
|
||||||
for a headless host.
|
for a headless host.
|
||||||
- **WiFi tx ceiling** ≈ 250 Mbps goodput (a Deck hardware/driver packet-rate limit, band-independent);
|
- **WiFi tx ceiling** ≈ 250 Mbps goodput (a Deck hardware/driver packet-rate limit, band-independent);
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
|
|||||||
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
|
||||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
have() { command -v "$1" >/dev/null 2>&1; }
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
# Create a system group if it is missing (needs sudo). Idempotent, and mirrors what the
|
||||||
|
# deb/rpm/arch scriptlets do — a udev rule that chgrp's to a group nobody created fails silently.
|
||||||
|
ensure_group() {
|
||||||
|
getent group "$1" >/dev/null 2>&1 && return 0
|
||||||
|
sudo groupadd --system "$1" 2>/dev/null || return 1
|
||||||
|
ok "created the '$1' system group"
|
||||||
|
}
|
||||||
|
|
||||||
# --- options ---------------------------------------------------------------
|
# --- options ---------------------------------------------------------------
|
||||||
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
||||||
@@ -147,7 +154,10 @@ if [ "$WITH_WEB" = 1 ]; then
|
|||||||
distrobox enter "$BOX" -- bash -lc "
|
distrobox enter "$BOX" -- bash -lc "
|
||||||
set -e
|
set -e
|
||||||
export PATH=\$HOME/.bun/bin:\$PATH
|
export PATH=\$HOME/.bun/bin:\$PATH
|
||||||
cd '$SRC/web' && bun install --frozen-lockfile && bun run build
|
# --ignore-scripts + explicit codegen: keep in step with scripts/steamdeck/update.sh, which
|
||||||
|
# explains why (web's `postinstall` writes the COMMITTED web/bun.nix; its `prepare`/codegen is
|
||||||
|
# required because src/api/gen, src/paraglide and src/routeTree.gen.ts are gitignored).
|
||||||
|
cd '$SRC/web' && bun install --frozen-lockfile --ignore-scripts && bun run codegen && bun run build
|
||||||
"
|
"
|
||||||
[ -f "$SRC/web/.output/server/index.mjs" ] || die "web build did not produce web/.output/server/index.mjs"
|
[ -f "$SRC/web/.output/server/index.mjs" ] || die "web build did not produce web/.output/server/index.mjs"
|
||||||
ok "web console built"
|
ok "web console built"
|
||||||
@@ -252,8 +262,21 @@ EOF
|
|||||||
)
|
)
|
||||||
chmod 600 "$CONFIG/web.env"
|
chmod 600 "$CONFIG/web.env"
|
||||||
ok "wrote web.env (generated login password)"
|
ok "wrote web.env (generated login password)"
|
||||||
else
|
elif [ "$WITH_WEB" = 1 ] && [ -f "$CONFIG/web.env" ]; then
|
||||||
[ "$WITH_WEB" = 1 ] && ok "web.env exists (login password unchanged)"
|
# THE belt the comment above promises. It used to live inside the create-only branch, so it
|
||||||
|
# only ever ran on files that had just been written 0600 anyway — every install that predates
|
||||||
|
# the L-19 fix still has its console password and session secret on disk at the Deck's ambient
|
||||||
|
# umask (0644, world-readable). Tighten it here, and say so out loud: a chmod does not un-leak
|
||||||
|
# a secret that was already readable by every local account, so the password needs rotating.
|
||||||
|
if find "$CONFIG/web.env" -maxdepth 0 -perm /0077 2>/dev/null | grep -q .; then
|
||||||
|
chmod 600 "$CONFIG/web.env"
|
||||||
|
warn "web.env was group/world-readable — an older install wrote it at the default umask."
|
||||||
|
warn "Tightened to 0600, but that does NOT un-expose the password it already leaked to every"
|
||||||
|
warn "local account. Rotate it: edit PUNKTFUNK_UI_PASSWORD in $CONFIG/web.env, then"
|
||||||
|
warn " systemctl --user restart punktfunk-web"
|
||||||
|
else
|
||||||
|
ok "web.env exists (login password unchanged, mode already 0600)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3b. HDR gamescope (punktfunk-gamescope, best-effort) ------------------
|
# --- 3b. HDR gamescope (punktfunk-gamescope, best-effort) ------------------
|
||||||
@@ -264,8 +287,8 @@ fi
|
|||||||
# host.env only while the binary provably runs on SteamOS.
|
# host.env only while the binary provably runs on SteamOS.
|
||||||
PUNKTFUNK_SRC="$SRC" PUNKTFUNK_BOX="$BOX" bash "$SRC/scripts/steamdeck/build-gamescope.sh"
|
PUNKTFUNK_SRC="$SRC" PUNKTFUNK_BOX="$BOX" bash "$SRC/scripts/steamdeck/build-gamescope.sh"
|
||||||
|
|
||||||
# --- 4. system tuning (needs sudo: UDP buffers + gamepad udev rule + vhci-hcd + input group) --------
|
# --- 4. system tuning (needs sudo: UDP buffers + udev rule + vhci-hcd + input/punktfunk groups) -----
|
||||||
log "System tuning (UDP buffers + gamepad rules + vhci-hcd + input group)"
|
log "System tuning (UDP buffers + gamepad rules + vhci-hcd + input/punktfunk groups)"
|
||||||
# sudo was acquired up front in preflight (SUDO_OK) so this never stalls behind the long build; a
|
# sudo was acquired up front in preflight (SUDO_OK) so this never stalls behind the long build; a
|
||||||
# skip here (no password / no TTY) was already reported loudly there.
|
# skip here (no password / no TTY) was already reported loudly there.
|
||||||
if [ "$SUDO_OK" = 1 ]; then
|
if [ "$SUDO_OK" = 1 ]; then
|
||||||
@@ -293,6 +316,34 @@ if [ "$SUDO_OK" = 1 ]; then
|
|||||||
NEED_RELOGIN=1
|
NEED_RELOGIN=1
|
||||||
warn "added $USER to the 'input' group (applies on next login)"
|
warn "added $USER to the 'input' group (applies on next login)"
|
||||||
fi
|
fi
|
||||||
|
# The 'punktfunk' group owns the usbip vhci attach/detach nodes (see 60-punktfunk.rules).
|
||||||
|
# Deliberately NOT 'input': writing 'attach' hands the kernel a caller-supplied socket fd and
|
||||||
|
# materialises an arbitrary emulated USB device — a root-only primitive that must not ride on
|
||||||
|
# the group every gamepad guide tells you to join (security-review 2026-08-05 M-4).
|
||||||
|
#
|
||||||
|
# The deb/rpm/arch scriptlets groupadd this; NOTHING on the Deck path did. So the rule we just
|
||||||
|
# installed ran `chgrp punktfunk` against a group that did not exist, the chgrp failed, the
|
||||||
|
# attach/detach files stayed root-only, and the native Steam Deck pad never attached — with no
|
||||||
|
# error anywhere the user would look. Create it and join it here: unlike a general-purpose
|
||||||
|
# host, running THIS script IS the statement "make my Deck a host with native pad passthrough".
|
||||||
|
# `if ensure_group` (not `ensure_group || true`): a failed groupadd must not fall through to a
|
||||||
|
# usermod against a group that does not exist, which under `set -e` would kill the installer
|
||||||
|
# here — after the long build and before the services are installed.
|
||||||
|
if ensure_group punktfunk; then
|
||||||
|
if id -nG "$USER" | grep -qw punktfunk; then
|
||||||
|
ok "already in the 'punktfunk' group (usbip vhci access)"
|
||||||
|
else
|
||||||
|
sudo usermod -aG punktfunk "$USER"
|
||||||
|
NEED_RELOGIN=1
|
||||||
|
warn "added $USER to the 'punktfunk' group — the native Steam Deck pad needs it. That group"
|
||||||
|
warn "can emulate arbitrary USB devices; drop it with 'sudo gpasswd -d $USER punktfunk' if"
|
||||||
|
warn "you would rather stream without the native pad."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "could not create the 'punktfunk' group — the native Steam Deck pad will not attach"
|
||||||
|
warn "(everything else works; the pad arrives as a generic Xbox 360 controller). By hand:"
|
||||||
|
warn " sudo groupadd --system punktfunk; sudo usermod -aG punktfunk $USER"
|
||||||
|
fi
|
||||||
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
|
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
|
||||||
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently
|
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently
|
||||||
# degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in
|
# degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in
|
||||||
@@ -303,15 +354,18 @@ if [ "$SUDO_OK" = 1 ]; then
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
warn "no usable sudo — SKIPPED system tuning. Gamepad passthrough + clean streaming need root (udev"
|
||||||
warn "rule, 'input' group, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
warn "rule, 'input' + 'punktfunk' groups, vhci-hcd, UDP buffers) — there is no user-space way to do these."
|
||||||
warn "A stock SteamOS 'deck' account has NO password, so sudo can't work until you set one:"
|
warn "A stock SteamOS 'deck' account has NO password, so sudo can't work until you set one:"
|
||||||
warn " passwd # set a sudo password once, then re-run this script"
|
warn " passwd # set a sudo password once, then re-run this script"
|
||||||
warn "Or apply it by hand (then reboot):"
|
warn "Or apply it by hand (then reboot):"
|
||||||
warn " sudo install -m644 $SRC/scripts/60-punktfunk.rules /etc/udev/rules.d/ &&"
|
warn " sudo install -m644 $SRC/scripts/60-punktfunk.rules /etc/udev/rules.d/ &&"
|
||||||
warn " sudo install -m644 $SRC/scripts/punktfunk-modules.conf /etc/modules-load.d/punktfunk.conf &&"
|
warn " sudo install -m644 $SRC/scripts/punktfunk-modules.conf /etc/modules-load.d/punktfunk.conf &&"
|
||||||
warn " sudo usermod -aG input $USER &&"
|
warn " sudo groupadd --system punktfunk;"
|
||||||
|
warn " sudo usermod -aG input,punktfunk $USER &&"
|
||||||
warn " printf 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432\\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf &&"
|
warn " printf 'net.core.wmem_max=33554432\\nnet.core.rmem_max=33554432\\n' | sudo tee /etc/sysctl.d/99-punktfunk-net.conf &&"
|
||||||
warn " sudo sysctl --system && sudo udevadm control --reload-rules && sudo udevadm trigger"
|
warn " sudo sysctl --system && sudo udevadm control --reload-rules && sudo udevadm trigger"
|
||||||
|
warn "('punktfunk' owns the usbip vhci nodes the native Steam Deck pad attaches through — without"
|
||||||
|
warn " it the pad silently never appears. Omit it if you do not want that pad.)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 5. systemd user services ---------------------------------------------
|
# --- 5. systemd user services ---------------------------------------------
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
|
|||||||
# found") aborted the whole update before the service restarts.
|
# found") aborted the whole update before the service restarts.
|
||||||
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
|
||||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
# Create a system group if it is missing (needs sudo). Idempotent, and mirrors what the
|
||||||
|
# deb/rpm/arch scriptlets do — a udev rule that chgrp's to a group nobody created fails silently.
|
||||||
|
ensure_group() {
|
||||||
|
getent group "$1" >/dev/null 2>&1 && return 0
|
||||||
|
sudo groupadd --system "$1" 2>/dev/null || return 1
|
||||||
|
ok "created the '$1' system group"
|
||||||
|
}
|
||||||
|
|
||||||
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
|
||||||
BOX="${PUNKTFUNK_BOX:-pf2}"
|
BOX="${PUNKTFUNK_BOX:-pf2}"
|
||||||
@@ -20,7 +27,28 @@ TARGET_DIR="$SRC/target-steamos"
|
|||||||
WEB=0; [ -f "$HOME/.config/systemd/user/punktfunk-web.service" ] && WEB=1
|
WEB=0; [ -f "$HOME/.config/systemd/user/punktfunk-web.service" ] && WEB=1
|
||||||
|
|
||||||
if [ "${1:-}" = "--pull" ]; then
|
if [ "${1:-}" = "--pull" ]; then
|
||||||
if [ -d "$SRC/.git" ]; then log "git pull"; git -C "$SRC" pull --ff-only; ok "pulled"; else die "$SRC is not a git checkout — rsync new source then run without --pull"; fi
|
[ -d "$SRC/.git" ] || die "$SRC is not a git checkout — rsync new source then run without --pull"
|
||||||
|
# web/bun.nix and sdk/bun.nix are GENERATED (bun2nix, a pure function of the matching bun.lock —
|
||||||
|
# packaging/nix/README.md) yet COMMITTED, because the Nix build fetches node_modules only from
|
||||||
|
# them. Until the --ignore-scripts fix below, web's `bun install` here ran its `postinstall`
|
||||||
|
# (`bun2nix -o bun.nix`) and rewrote that tracked file on every single update. That is invisible
|
||||||
|
# while the committed file is in sync — but main carried a STALE web/bun.nix from 1db8f763 to
|
||||||
|
# b79d90b4, so any Deck updated in that window had the file rewritten to the *correct* content
|
||||||
|
# and has been sitting dirty ever since. The next `git pull --ff-only` that touches it then dies
|
||||||
|
# with "Your local changes to the following files would be overwritten by merge", and the update
|
||||||
|
# stops before a single service is restarted.
|
||||||
|
#
|
||||||
|
# Restore ONLY these two derived paths. Not a blanket `git reset --hard`: $SRC is the operator's
|
||||||
|
# own checkout (they may have patched a source file, or be carrying a cherry-pick), and silently
|
||||||
|
# deleting that to save an update is a far worse trade than one legible error. Discarding these
|
||||||
|
# two is provably lossless — regenerating them from the lockfiles is exactly what bun2nix does.
|
||||||
|
git -C "$SRC" checkout -- web/bun.nix sdk/bun.nix 2>/dev/null || true
|
||||||
|
log "git pull"
|
||||||
|
git -C "$SRC" pull --ff-only \
|
||||||
|
|| die "git pull --ff-only failed in $SRC. If it named locally-modified files, this checkout
|
||||||
|
has local changes: review them with 'git -C $SRC status', then commit or stash them (or discard
|
||||||
|
one with 'git -C $SRC checkout -- <file>') and re-run. Nothing was rebuilt or restarted."
|
||||||
|
ok "pulled"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Rebuilding host (release)"
|
log "Rebuilding host (release)"
|
||||||
@@ -29,7 +57,14 @@ distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH
|
|||||||
ok "host rebuilt"
|
ok "host rebuilt"
|
||||||
if [ "$WEB" = 1 ]; then
|
if [ "$WEB" = 1 ]; then
|
||||||
log "Rebuilding web console"
|
log "Rebuilding web console"
|
||||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/web' && bun install --frozen-lockfile && bun run build"
|
# --ignore-scripts, then `bun run codegen` explicitly: web has TWO install lifecycle scripts and
|
||||||
|
# we want exactly one of them. `prepare` (= codegen: orval + paraglide + the i18n check) is
|
||||||
|
# REQUIRED — src/api/gen, src/paraglide and src/routeTree.gen.ts are gitignored, and `prebuild`
|
||||||
|
# only re-runs orval, so dropping codegen leaves the build without its i18n messages. But
|
||||||
|
# `postinstall` (`bun2nix -o bun.nix`) writes a COMMITTED file, and an updater must never dirty
|
||||||
|
# the tree it just pulled into — that is what broke `--pull` above. The SDK install below has
|
||||||
|
# always passed --ignore-scripts, which is why only web/bun.nix ever went dirty.
|
||||||
|
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.bun/bin:\$PATH; cd '$SRC/web' && bun install --frozen-lockfile --ignore-scripts && bun run codegen && bun run build"
|
||||||
ok "web rebuilt"
|
ok "web rebuilt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -81,11 +116,27 @@ EOF
|
|||||||
ok "punktfunk-rebuild-check.service installed (auto-rebuild after SteamOS updates)"
|
ok "punktfunk-rebuild-check.service installed (auto-rebuild after SteamOS updates)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
CONFIG="$HOME/.config/punktfunk"
|
||||||
|
|
||||||
|
# Secret hygiene, retrofitted. install.sh §3 does this for fresh installs — but only install.sh
|
||||||
|
# ever did, so a Deck that was set up once and only ever *updated* since kept the old modes
|
||||||
|
# forever. This directory holds web.env (console login password + session secret), the mgmt token
|
||||||
|
# and the host key; a plain `mkdir -p` left it 0755 at the Deck's ambient umask and web.env itself
|
||||||
|
# 0644, i.e. readable by every local account (2026-08-05 review L-19). Both chmods are idempotent.
|
||||||
|
[ -d "$CONFIG" ] && chmod 700 "$CONFIG" 2>/dev/null || true
|
||||||
|
if [ -f "$CONFIG/web.env" ] && find "$CONFIG/web.env" -maxdepth 0 -perm /0077 2>/dev/null | grep -q .; then
|
||||||
|
chmod 600 "$CONFIG/web.env"
|
||||||
|
warn "web.env was group/world-readable — an older install wrote it at the default umask."
|
||||||
|
warn "Tightened to 0600, but that does NOT un-expose the password it already leaked to every"
|
||||||
|
warn "local account. Rotate it: edit PUNKTFUNK_UI_PASSWORD in $CONFIG/web.env, then"
|
||||||
|
warn " systemctl --user restart punktfunk-web"
|
||||||
|
fi
|
||||||
|
|
||||||
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
|
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
|
||||||
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
|
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
|
||||||
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
|
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
|
||||||
# KWin only grants the restricted capture/input globals to the exe a .desktop authorizes.
|
# KWin only grants the restricted capture/input globals to the exe a .desktop authorizes.
|
||||||
HOST_ENV="$HOME/.config/punktfunk/host.env"
|
HOST_ENV="$CONFIG/host.env"
|
||||||
if [ -f "$HOST_ENV" ] && ! grep -q '^RADV_PERFTEST=' "$HOST_ENV"; then
|
if [ -f "$HOST_ENV" ] && ! grep -q '^RADV_PERFTEST=' "$HOST_ENV"; then
|
||||||
printf '\n# Van Gogh RADV gates VK_KHR_video_encode_* behind this (Vulkan Video encode).\nRADV_PERFTEST=video_encode\n' >> "$HOST_ENV"
|
printf '\n# Van Gogh RADV gates VK_KHR_video_encode_* behind this (Vulkan Video encode).\nRADV_PERFTEST=video_encode\n' >> "$HOST_ENV"
|
||||||
ok "host.env: added RADV_PERFTEST=video_encode"
|
ok "host.env: added RADV_PERFTEST=video_encode"
|
||||||
@@ -128,6 +179,25 @@ if [ "$SUDO_OK" = 1 ]; then
|
|||||||
sudo usermod -aG input "$USER"
|
sudo usermod -aG input "$USER"
|
||||||
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
|
warn "added $USER to the 'input' group — REBOOT (or log out/in) for it to apply"
|
||||||
fi
|
fi
|
||||||
|
# 'punktfunk' owns the usbip vhci attach/detach nodes (60-punktfunk.rules), deliberately NOT
|
||||||
|
# 'input' — writing 'attach' materialises an arbitrary emulated USB device, a root-only kernel
|
||||||
|
# primitive that must not ride on the group every gamepad guide tells you to join
|
||||||
|
# (security-review 2026-08-05 M-4). No Deck install ever created it, so the rule's chgrp failed
|
||||||
|
# and the native Steam Deck pad silently never attached. Retrofit both group and membership.
|
||||||
|
# `if ensure_group` (not `ensure_group || true`): a failed groupadd must not fall through to a
|
||||||
|
# usermod against a group that does not exist — under `set -e` that would abort the update
|
||||||
|
# before the service restarts at the bottom, leaving the host down.
|
||||||
|
if ensure_group punktfunk; then
|
||||||
|
if id -nG "$USER" | grep -qw punktfunk; then :; else
|
||||||
|
sudo usermod -aG punktfunk "$USER"
|
||||||
|
warn "added $USER to the 'punktfunk' group (usbip vhci — the native Steam Deck pad needs it)"
|
||||||
|
warn " — REBOOT (or log out/in) for it to apply. That group can emulate arbitrary USB"
|
||||||
|
warn " devices; 'sudo gpasswd -d $USER punktfunk' drops it if you do not want the native pad."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "could not create the 'punktfunk' group — the native Steam Deck pad will not attach."
|
||||||
|
warn "By hand: sudo groupadd --system punktfunk; sudo usermod -aG punktfunk $USER"
|
||||||
|
fi
|
||||||
# Register the tuning on Valve's atomic-update preserve list (see install.sh §4): without
|
# Register the tuning on Valve's atomic-update preserve list (see install.sh §4): without
|
||||||
# this, every SteamOS A/B update strips the three files above again (verified live —
|
# this, every SteamOS A/B update strips the three files above again (verified live —
|
||||||
# gamepads silently degrade to Xbox 360, UDP buffers back to 208 KB).
|
# gamepads silently degrade to Xbox 360, UDP buffers back to 208 KB).
|
||||||
|
|||||||
+12
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@punktfunk/host",
|
"name": "@punktfunk/host",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
@@ -13,7 +13,13 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://git.unom.io/unom/punktfunk/issues"
|
"url": "https://git.unom.io/unom/punktfunk/issues"
|
||||||
},
|
},
|
||||||
"keywords": ["punktfunk", "game-streaming", "automation", "sdk", "effect"],
|
"keywords": [
|
||||||
|
"punktfunk",
|
||||||
|
"game-streaming",
|
||||||
|
"automation",
|
||||||
|
"sdk",
|
||||||
|
"effect"
|
||||||
|
],
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -29,7 +35,10 @@
|
|||||||
"bin": {
|
"bin": {
|
||||||
"punktfunk-scripting": "./dist/runner-cli.js"
|
"punktfunk-scripting": "./dist/runner-cli.js"
|
||||||
},
|
},
|
||||||
"files": ["dist", "README.md"],
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { configDir } from "./config.js";
|
import { configDir } from "./config.js";
|
||||||
|
import { SDK_VERSION } from "./version.js";
|
||||||
|
|
||||||
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
|
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
|
||||||
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
|
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
|
||||||
@@ -187,6 +188,109 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The SDK version installed in a plugins tree, or undefined if it isn't installed at all. */
|
||||||
|
export const installedSdkVersion = (
|
||||||
|
dir = pluginsDirDefault(),
|
||||||
|
): string | undefined => {
|
||||||
|
try {
|
||||||
|
const manifest = path.join(
|
||||||
|
dir,
|
||||||
|
"node_modules",
|
||||||
|
"@punktfunk",
|
||||||
|
"host",
|
||||||
|
"package.json",
|
||||||
|
);
|
||||||
|
const v = (
|
||||||
|
JSON.parse(fs.readFileSync(manifest, "utf8")) as { version?: string }
|
||||||
|
).version;
|
||||||
|
return typeof v === "string" ? v : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the plugins tree's `@punktfunk/host` up to the version THIS runner was built from.
|
||||||
|
*
|
||||||
|
* **Why this exists.** The SDK is the seam every plugin registers through, but each plugin resolves
|
||||||
|
* it from the plugins tree, and `bun.lock` pins it to an exact version with an integrity hash. No
|
||||||
|
* user-facing flow re-resolves that pin: installing a plugin, reinstalling it, even updating it to a
|
||||||
|
* newer release all leave the SDK where it is, because the plugin's `^0.1.x` range is already
|
||||||
|
* satisfied. Measured on 2026-08-08 — publishing `@punktfunk/host@0.1.3` (the release that lets a
|
||||||
|
* library scanner register `category`, so it stays out of the console nav) reached **no existing
|
||||||
|
* install**, and the only thing that moved it was deleting the lockfile by hand over ssh. Shipping a
|
||||||
|
* fix that needs an ssh session is not shipping a fix.
|
||||||
|
*
|
||||||
|
* The runner is the right owner: it is bundled from this same `sdk/` at the host's release commit
|
||||||
|
* (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the punktfunk-scripting package), so
|
||||||
|
* `SDK_VERSION` is by construction the SDK that matches the host now on disk. A host upgrade then
|
||||||
|
* carries the SDK with it and nobody touches a runner.
|
||||||
|
*
|
||||||
|
* **Why the whole lockfile.** A targeted `bun add @punktfunk/host@<v>` at the root does NOT work
|
||||||
|
* while plugins still declare the SDK in their own `dependencies` (they do, though none import it):
|
||||||
|
* bun honours their locked resolution and gives each plugin a private nested copy, which then
|
||||||
|
* SHADOWS the root — measured, 5 nested copies. A lockless resolve hoists one copy for everyone,
|
||||||
|
* also measured. Once the plugins drop that spurious dependency this can become the targeted form.
|
||||||
|
*
|
||||||
|
* Safety: the plugins' own versions are pinned exactly in the root `package.json`, so a re-resolve
|
||||||
|
* cannot move them; only shared transitive deps float within their declared ranges. The lockfile is
|
||||||
|
* backed up first and restored if the install fails, and any failure is logged and swallowed — a
|
||||||
|
* dependency refresh must never stop the plugins that are already working from loading.
|
||||||
|
*/
|
||||||
|
export const reconcileSharedSdk = (
|
||||||
|
dir = pluginsDirDefault(),
|
||||||
|
log: (line: string) => void = (l) => console.log(l),
|
||||||
|
): void => {
|
||||||
|
const have = installedSdkVersion(dir);
|
||||||
|
// Nothing installed = no plugins yet; the first `bun add` resolves the current SDK on its own.
|
||||||
|
if (have === undefined || have === SDK_VERSION) return;
|
||||||
|
|
||||||
|
const lock = path.join(dir, "bun.lock");
|
||||||
|
const backup = `${lock}.pf-bak`;
|
||||||
|
log(
|
||||||
|
`[plugins] @punktfunk/host ${have} installed, this host ships ${SDK_VERSION} — refreshing`,
|
||||||
|
);
|
||||||
|
let restore = false;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(lock)) {
|
||||||
|
fs.copyFileSync(lock, backup);
|
||||||
|
fs.rmSync(lock);
|
||||||
|
restore = true;
|
||||||
|
}
|
||||||
|
const res = Bun.spawnSync([process.execPath, "install", "--ignore-scripts"], {
|
||||||
|
cwd: dir,
|
||||||
|
stdio: ["inherit", "inherit", "inherit"],
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(`bun install exited ${res.exitCode ?? "?"}`);
|
||||||
|
}
|
||||||
|
const now = installedSdkVersion(dir);
|
||||||
|
if (now !== SDK_VERSION) {
|
||||||
|
// The install "succeeded" and still did not deliver the version — better to sit on the
|
||||||
|
// known-good tree than to keep a half-resolved one.
|
||||||
|
throw new Error(`still ${now ?? "absent"} after install`);
|
||||||
|
}
|
||||||
|
restore = false;
|
||||||
|
if (fs.existsSync(backup)) fs.rmSync(backup);
|
||||||
|
log(`[plugins] @punktfunk/host is now ${SDK_VERSION}`);
|
||||||
|
} catch (e) {
|
||||||
|
log(
|
||||||
|
`[plugins] WARNING: could not refresh @punktfunk/host (${
|
||||||
|
e instanceof Error ? e.message : e
|
||||||
|
}) — plugins keep running against ${have}`,
|
||||||
|
);
|
||||||
|
if (restore && fs.existsSync(backup)) {
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(backup, lock);
|
||||||
|
fs.rmSync(backup);
|
||||||
|
} catch {
|
||||||
|
// The backup is still on disk under its own name; say so rather than pretend.
|
||||||
|
log(`[plugins] the previous lockfile is at ${backup}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Install one or more plugins by friendly name or package. */
|
/** Install one or more plugins by friendly name or package. */
|
||||||
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
|
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
|
||||||
const pkgs = names.map((n) => resolvePackage(n, opts));
|
const pkgs = names.map((n) => resolvePackage(n, opts));
|
||||||
|
|||||||
+13
-1
@@ -23,7 +23,12 @@
|
|||||||
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
||||||
import { Effect, Fiber } from "effect";
|
import { Effect, Fiber } from "effect";
|
||||||
import { installLogShipper } from "./log-ship.js";
|
import { installLogShipper } from "./log-ship.js";
|
||||||
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
import {
|
||||||
|
addPlugins,
|
||||||
|
listInstalled,
|
||||||
|
reconcileSharedSdk,
|
||||||
|
removePlugins,
|
||||||
|
} from "./plugins.js";
|
||||||
import { discoverUnits, runner } from "./runner.js";
|
import { discoverUnits, runner } from "./runner.js";
|
||||||
|
|
||||||
const arg = (flag: string): string | undefined => {
|
const arg = (flag: string): string | undefined => {
|
||||||
@@ -166,6 +171,13 @@ const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
|||||||
// a plugin failing to load are the first ones out.
|
// a plugin failing to load are the first ones out.
|
||||||
const shipper = installLogShipper();
|
const shipper = installLogShipper();
|
||||||
|
|
||||||
|
// Before any plugin loads: make the tree's shared SDK the one this runner was built from. A host
|
||||||
|
// upgrade is the only moment that can deliver an SDK fix to already-installed plugins, and this is
|
||||||
|
// that moment — see `reconcileSharedSdk`. Deliberately AFTER the log shipper so the operator can
|
||||||
|
// read what it did from the console's Logs page, and BEFORE `runner()` so plugins import the
|
||||||
|
// refreshed copy rather than the one they were started with.
|
||||||
|
reconcileSharedSdk(options.pluginsDir);
|
||||||
|
|
||||||
const fiber = Effect.runFork(runner(options));
|
const fiber = Effect.runFork(runner(options));
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
const shutdown = (signal: string) => {
|
const shutdown = (signal: string) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user