Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3721b6816d | ||
|
|
0e0d5b8b4d | ||
|
|
6be865f33f | ||
|
|
f3615f83a5 | ||
|
|
eae1837a3a | ||
|
|
ba89b9fcd0 | ||
|
|
622817954a | ||
|
|
ab58fd2f0e | ||
|
|
1945052e66 | ||
|
|
9e1a686795 | ||
|
|
19d6f79d2d | ||
|
|
94f0207cbd | ||
|
|
2c69cbdab9 | ||
|
|
d8d8c6c43d | ||
|
|
b8b8ac336c | ||
|
|
6d1baa0add | ||
|
|
53640b8754 | ||
|
|
94b7a834cb | ||
|
|
4114dfeff7 | ||
|
|
37781a610f | ||
|
|
ec9aa415f6 | ||
|
|
24d2f97eae | ||
|
|
c9b8f666cd | ||
|
|
35b6c835fd | ||
|
|
39a9f09f04 | ||
|
|
f57fc85d34 | ||
|
|
0323158a3e | ||
|
|
980b399ea8 | ||
|
|
d5857abd91 | ||
|
|
0c261de636 | ||
|
|
6e07d063c3 | ||
|
|
dff63b2a29 | ||
|
|
02b94b828d | ||
|
|
e44f2b1024 | ||
|
|
ddc28de32a | ||
|
|
1839d7566b | ||
|
|
3d89301336 | ||
|
|
1ee06defa6 |
@@ -99,3 +99,41 @@ jobs:
|
||||
mkdir -p ~/unom-flatpak/site/repo
|
||||
cd ~/unom-flatpak
|
||||
docker compose -f compose.production.yml up -d
|
||||
|
||||
winget:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Sync winget source compose + server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix).
|
||||
source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs"
|
||||
target: "~/unom-winget"
|
||||
strip_components: 3
|
||||
overwrite: true
|
||||
|
||||
- name: Start winget REST source
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each
|
||||
# stable tag (same content/config split as the flatpak repo). Ensure the bind-mount
|
||||
# source exists so the container starts; it serves 503 until the first catalogue lands.
|
||||
mkdir -p ~/unom-winget/data
|
||||
cd ~/unom-winget
|
||||
docker compose -f compose.production.yml up -d
|
||||
# Surface a missing catalogue here rather than letting winget report "no package found".
|
||||
sleep 3
|
||||
curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it"
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# it does not, so module resolution dies at the first step and every `@punktfunk/host` import
|
||||
# reads as "cannot find module". Replacing the tree with a real copy is the whole fix; drop
|
||||
# this step once bun links `file:` deps correctly again.
|
||||
- name: Repair the file: dependency (bun 1.3 self-symlink)
|
||||
- name: "Repair the file: dependency (bun 1.3 self-symlink)"
|
||||
working-directory: plugin-kit
|
||||
run: |
|
||||
# -f follows the link, so this is true only when the manifest actually resolves.
|
||||
|
||||
@@ -359,3 +359,88 @@ jobs:
|
||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
||||
}
|
||||
|
||||
# winget manifests for the release just attached above. Runs AFTER the attach step so the
|
||||
# InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
|
||||
# manifest published ahead of its artifact is a hard 404 for every client that picks it up.
|
||||
# Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
|
||||
# alias has nothing it could point at.
|
||||
- name: Emit + attach winget manifests (stable tags only)
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
& scripts/ci/winget-manifest.ps1 `
|
||||
-Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
|
||||
. scripts/ci/gitea-release.ps1
|
||||
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
|
||||
foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
|
||||
Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
|
||||
}
|
||||
|
||||
# Republish the winget REST source on unom-1 once the release above carries its manifests.
|
||||
#
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
|
||||
# so the result cannot drift and re-running any tag reproduces it byte for byte.
|
||||
- name: Build + test the source catalogue
|
||||
working-directory: packaging/winget/server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm install --no-audit --no-fund
|
||||
node build-data.mjs --out data/data.json
|
||||
# A wrong response SHAPE does not fail loudly — winget just reports "no package found".
|
||||
# Gate on the suite before anything reaches the box.
|
||||
node test.mjs
|
||||
|
||||
# Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
|
||||
# flatpak repo's content and config deploy on separate paths.
|
||||
- name: Ship the catalogue to unom-1
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "packaging/winget/server/data/data.json"
|
||||
target: "~/unom-winget/data"
|
||||
strip_components: 4
|
||||
overwrite: true
|
||||
|
||||
# No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
|
||||
# one actually being served, and fails the release if it is not.
|
||||
- name: Verify the served catalogue
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
curl -fsS http://127.0.0.1:3240/healthz
|
||||
echo
|
||||
curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
|
||||
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
|
||||
| grep -q "${GITHUB_REF_NAME#v}" \
|
||||
|| { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
|
||||
echo "winget source serving ${GITHUB_REF_NAME#v}"
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ gitea.ref_name }}
|
||||
# `env:` populates the RUNNER's environment; this action runs `script` on the REMOTE
|
||||
# host, which inherits nothing from it. `envs:` is the action's own allow-list of names
|
||||
# to forward into the remote shell — without it `set -u` aborted at the first expansion
|
||||
# ("GITHUB_REF_NAME: unbound variable") and the step failed on every tag, after the
|
||||
# catalogue had already shipped correctly.
|
||||
envs: GITHUB_REF_NAME
|
||||
|
||||
Generated
+35
-27
@@ -1020,6 +1020,13 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
@@ -2194,7 +2201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2299,7 +2306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2844,7 +2851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2868,7 +2875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2886,7 +2893,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2907,7 +2914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2931,7 +2938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2940,7 +2947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2952,7 +2959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2966,11 +2973,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2999,14 +3006,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3021,7 +3028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3048,11 +3055,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3064,7 +3072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3272,7 +3280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3288,7 +3296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3304,7 +3312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3319,7 +3327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3338,7 +3346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3370,7 +3378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3454,7 +3462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3468,7 +3476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3491,7 +3499,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@ members = [
|
||||
"clients/session",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/display-disturb",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
]
|
||||
@@ -48,7 +49,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.2"
|
||||
version = "0.20.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
|
||||
| **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
|
||||
| **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
|
||||
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
|
||||
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
|
||||
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
|
||||
|
||||
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
|
||||
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||
|
||||
+263
-149
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.19.2"
|
||||
"version": "0.20.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -965,7 +965,7 @@
|
||||
"library"
|
||||
],
|
||||
"summary": "List the game library",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns.",
|
||||
"description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns; `?platform=` to one platform (case-insensitive —\ninstalled-store titles are `PC`, custom/provider entries carry whatever was authored).",
|
||||
"operationId": "getLibrary",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -976,6 +976,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"in": "query",
|
||||
"description": "Only entries on this platform (case-insensitive, e.g. `PS2`)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4012,95 +4021,111 @@
|
||||
}
|
||||
},
|
||||
"CustomEntry": {
|
||||
"type": "object",
|
||||
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits.",
|
||||
"required": [
|
||||
"id",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
|
||||
},
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
|
||||
},
|
||||
"external_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"title"
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns."
|
||||
},
|
||||
"external_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])."
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits."
|
||||
},
|
||||
"CustomInput": {
|
||||
"type": "object",
|
||||
"description": "Request body to create or replace a custom entry (no `id` — the host owns it).",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept."
|
||||
},
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Request body to create or replace a custom entry (no `id` — the host owns it)."
|
||||
},
|
||||
"CustomPreset": {
|
||||
"type": "object",
|
||||
@@ -4750,48 +4775,129 @@
|
||||
]
|
||||
},
|
||||
"GameEntry": {
|
||||
"type": "object",
|
||||
"description": "One title in the unified library, regardless of which store it came from.",
|
||||
"required": [
|
||||
"id",
|
||||
"store",
|
||||
"title",
|
||||
"art"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata, flattened — see [`GameMeta`]."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
"example": "steam:570"
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"store",
|
||||
"title",
|
||||
"art"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec",
|
||||
"description": "How the host would launch it, when known."
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`.",
|
||||
"example": "steam:570"
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec",
|
||||
"description": "How the host would launch it, when known."
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
"example": "steam"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "One title in the unified library, regardless of which store it came from."
|
||||
},
|
||||
"GameMeta": {
|
||||
"type": "object",
|
||||
"description": "Descriptive metadata for a title — everything a richer library UI (details pane, platform\nfilter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to\nabsent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is\n`#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat\nwire shape everywhere.\n\nValues are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)\neach have their own vocabulary and the host has no business normalizing it.",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it."
|
||||
"description": "Short blurb for a details pane."
|
||||
},
|
||||
"store": {
|
||||
"type": "string",
|
||||
"description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.",
|
||||
"example": "steam"
|
||||
"developer": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
"genres": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)."
|
||||
},
|
||||
"platform": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive).",
|
||||
"example": "PS2"
|
||||
},
|
||||
"players": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Maximum simultaneous (local) players.",
|
||||
"minimum": 0
|
||||
},
|
||||
"publisher": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"region": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)."
|
||||
},
|
||||
"release_year": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int32",
|
||||
"description": "Year of first release — the granularity metadata sources reliably agree on.",
|
||||
"example": 2001,
|
||||
"minimum": 0
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6003,45 +6109,53 @@
|
||||
}
|
||||
},
|
||||
"ProviderEntryInput": {
|
||||
"type": "object",
|
||||
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.",
|
||||
"required": [
|
||||
"external_id",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/GameMeta",
|
||||
"description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]."
|
||||
},
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
|
||||
},
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"external_id",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"art": {
|
||||
"$ref": "#/components/schemas/Artwork"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
"detect": {
|
||||
"$ref": "#/components/schemas/DetectHint",
|
||||
"description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits."
|
||||
},
|
||||
"external_id": {
|
||||
"type": "string",
|
||||
"description": "The provider's stable id for this title (the reconcile diff key)."
|
||||
},
|
||||
"launch": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LaunchSpec"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"prep": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PrepCmd"
|
||||
},
|
||||
"description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config."
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key."
|
||||
},
|
||||
"ProviderRemoved": {
|
||||
"type": "object",
|
||||
@@ -6519,7 +6633,7 @@
|
||||
"mbps": {
|
||||
"type": "number",
|
||||
"format": "float",
|
||||
"description": "Transmit goodput (Mb/s)."
|
||||
"description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops."
|
||||
},
|
||||
"packets_dropped": {
|
||||
"type": "integer",
|
||||
|
||||
@@ -620,6 +620,7 @@ fn mock_library() -> (
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
|
||||
@@ -13,8 +13,17 @@
|
||||
// console behind the couch UI looks like a crash.
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
|
||||
// The shared client log sink (std-only): a couch launch has no console, so the session's
|
||||
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
|
||||
// initializes + forwards; the module's subscriber-side surface stays unused here.
|
||||
#[cfg(windows)]
|
||||
#[path = "../logfile.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod logfile;
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
logfile::init();
|
||||
// The session binary ships beside us in the package; fall back to PATH for a dev run.
|
||||
let session = std::env::current_exe()
|
||||
.ok()
|
||||
@@ -27,7 +36,14 @@ fn main() {
|
||||
if !std::env::args().any(|a| a == "--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(_) => std::process::exit(1),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
|
||||
//!
|
||||
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
|
||||
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
|
||||
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
|
||||
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
|
||||
//! latency-sawtooth field report had to be triaged from host logs alone because the client
|
||||
//! side had nowhere to land.
|
||||
//!
|
||||
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
||||
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
||||
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// Rotate at the next start once the file exceeds this (the host's cap).
|
||||
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
||||
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
||||
}
|
||||
|
||||
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
|
||||
pub(crate) fn path() -> Option<PathBuf> {
|
||||
Some(log_dir()?.join("client.log"))
|
||||
}
|
||||
|
||||
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
||||
/// subscriber installs; every later [`tee`] shares the handle.
|
||||
pub(crate) fn init() {
|
||||
SINK.get_or_init(|| {
|
||||
let dir = log_dir()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let path = dir.join("client.log");
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
|
||||
let old = dir.join("client.log.old");
|
||||
// Windows `rename` refuses an existing destination — drop the old generation first.
|
||||
let _ = std::fs::remove_file(&old);
|
||||
let _ = std::fs::rename(&path, &old);
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
Some(Arc::new(Mutex::new(file)))
|
||||
});
|
||||
}
|
||||
|
||||
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
|
||||
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
|
||||
/// `with_writer` factory and the session-stderr forwarder both use it.
|
||||
pub(crate) struct Tee;
|
||||
|
||||
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
|
||||
pub(crate) fn tee() -> Tee {
|
||||
Tee
|
||||
}
|
||||
|
||||
impl Write for Tee {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let _ = io::stderr().write_all(buf);
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().write_all(buf);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
let _ = io::stderr().flush();
|
||||
if let Some(Some(f)) = SINK.get() {
|
||||
let _ = f.lock().unwrap().flush();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
|
||||
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
|
||||
/// pipe (child exit).
|
||||
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-session-log".into())
|
||||
.spawn(move || {
|
||||
let mut reader = io::BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
let mut tee = Tee;
|
||||
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
|
||||
let _ = tee.write_all(line.as_bytes());
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ mod discovery;
|
||||
#[cfg(windows)]
|
||||
mod gpu;
|
||||
#[cfg(windows)]
|
||||
mod logfile;
|
||||
#[cfg(windows)]
|
||||
mod probe;
|
||||
#[cfg(windows)]
|
||||
mod shell_window;
|
||||
@@ -49,11 +51,20 @@ fn main() {
|
||||
}
|
||||
set_app_user_model_id();
|
||||
|
||||
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
|
||||
// a GUI/MSIX launch has no console, so without the file the client side of any field report
|
||||
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
|
||||
logfile::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_ansi(false)
|
||||
.with_writer(logfile::tee)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
if let Some(p) = logfile::path() {
|
||||
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
|
||||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let flag = |name: &str| args.iter().any(|a| a == name);
|
||||
@@ -88,7 +99,16 @@ fn main() {
|
||||
if !flag("--windowed") {
|
||||
cmd.arg("--fullscreen");
|
||||
}
|
||||
match cmd.status() {
|
||||
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
|
||||
// (Start-menu tile, Steam shortcut) has no console to inherit either.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
let run = cmd.spawn().and_then(|mut child| {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
child.wait()
|
||||
});
|
||||
match run {
|
||||
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
|
||||
Err(e) => {
|
||||
eprintln!("could not start the console UI: {e}");
|
||||
|
||||
@@ -169,13 +169,19 @@ fn spawn_with(
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
|
||||
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
|
||||
// had, and GUI runs — which have no console — finally keep the session's whole
|
||||
// receive/decode/present log in the client log file.
|
||||
.stderr(Stdio::piped())
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
|
||||
tracing::info!(host = %host_label, "session binary spawned");
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
crate::logfile::forward_child_stderr(stderr);
|
||||
}
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
// Park the child where the kill handle (and the reader, for the final reap) reach it.
|
||||
*slot.0.lock().unwrap() = Some(child);
|
||||
|
||||
@@ -732,6 +732,18 @@ impl IddPushCapturer {
|
||||
return; // no new sample since last consume
|
||||
}
|
||||
self.desc_seq = seq;
|
||||
// A topology reassert is in flight (the exclusive watchdog announced it): every sample in
|
||||
// this window is potentially the TRANSIENT eviction state, and acting on one recreates
|
||||
// the ring at a mode the reassert's recovery chain (`recreate_ring_in_place`, keyed off
|
||||
// the reassert generation) is about to undo — the field hdr=true→false→true double
|
||||
// recreate. Consume the sample, disarm the debounce, act on nothing; a REAL change that
|
||||
// races the window survives it (the descriptor still differs once the hold clears, and
|
||||
// the poller re-samples in ~250 ms). This also keeps the negotiated-depth pin-back below
|
||||
// from issuing a CCD write mid-eviction, where it would fight the reassert itself.
|
||||
if pf_win_display::topology_churn::held() {
|
||||
self.pending_desc = None;
|
||||
return;
|
||||
}
|
||||
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
|
||||
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring
|
||||
// is never recreated at the wrong format):
|
||||
|
||||
@@ -115,7 +115,7 @@ impl StallWatch {
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_externals();
|
||||
let suspects = pf_win_display::display_events::connected_inactive_physicals();
|
||||
let suspects = if suspects.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
@@ -151,10 +151,13 @@ impl StallWatch {
|
||||
display-poller software (the SteelSeries-GG/SignalRGB class — \
|
||||
correlate 'slow display-descriptor poll' lines), or the DWM present \
|
||||
clock (try a different refresh rate). If connected_inactive lists a \
|
||||
display, its standby probing is the prime suspect: unplug it at the \
|
||||
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
|
||||
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
|
||||
streaming"
|
||||
display, its standby servicing is the prime suspect. For a LAPTOP \
|
||||
PANEL (the exclusive isolate deactivated it — the dark-but-connected \
|
||||
head is itself the disturbance on hybrid laptops): keep it active \
|
||||
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
|
||||
For an external display: unplug it at the GPU, disable its OSD auto \
|
||||
input scan (TVs: instant-on/quick-start + CEC off), use an \
|
||||
HPD-holding adapter/dummy, or keep it active while streaming"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ pub struct GameEntry {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub art: Artwork,
|
||||
/// The system the title runs on (`"PC"`, `"PS2"`, …) — free-form display string from the
|
||||
/// host's flattened `GameMeta`; the rest of the metadata is not decoded until a UI needs it.
|
||||
#[serde(default)]
|
||||
pub platform: Option<String>,
|
||||
}
|
||||
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
@@ -280,7 +284,7 @@ mod tests {
|
||||
fn game_entry_decodes_the_wire_shape() {
|
||||
// The exact shape mgmt.rs serializes (optional art fields omitted, launch ignored).
|
||||
let json = r#"[
|
||||
{"id":"steam:570","store":"steam","title":"Dota 2",
|
||||
{"id":"steam:570","store":"steam","title":"Dota 2","platform":"PC",
|
||||
"art":{"portrait":"/api/v1/library/art/steam:570/portrait"},
|
||||
"launch":{"kind":"steam_appid","value":"570"}},
|
||||
{"id":"custom:abc","store":"custom","title":"My Emu","art":{}}
|
||||
@@ -288,7 +292,12 @@ mod tests {
|
||||
let games: Vec<GameEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(games.len(), 2);
|
||||
assert_eq!(games[0].id, "steam:570");
|
||||
assert_eq!(games[0].platform.as_deref(), Some("PC"));
|
||||
assert!(games[1].art.portrait.is_none());
|
||||
assert!(
|
||||
games[1].platform.is_none(),
|
||||
"pre-metadata hosts still parse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1613,14 +1613,22 @@ impl Encoder for NvencCudaEncoder {
|
||||
// `async_rt` must be absent too: in two-thread mode the frame may be recycled right after
|
||||
// submit returns while the stream still holds its copy (belt-and-braces — an escalated
|
||||
// session was rebuilt without the binding, so `stream_ordered` is false there anyway).
|
||||
// Cursor-bearing frames additionally force the CPU-synced path: the Vulkan blend sits
|
||||
// between the CUDA copy and the encode, and its cross-API ordering is fence/CPU-
|
||||
// established, not stream-ordered. Frames without a cursor (games hide it; client-draws
|
||||
// sessions strip it) keep the stream-ordered fast path untouched.
|
||||
let ordered = self.stream_ordered
|
||||
&& self.async_rt.is_none()
|
||||
&& self.pending.is_empty()
|
||||
&& captured.cursor.is_none();
|
||||
let base_ordered =
|
||||
self.stream_ordered && self.async_rt.is_none() && self.pending.is_empty();
|
||||
// Cursor-bearing frames stay on the fast path when the blend itself can be stream-
|
||||
// ordered: the Vulkan dispatch waits/advances a timeline semaphore CUDA also holds, so
|
||||
// copy→blend→encode orders entirely on-device (`VkSlotBlend::blend_ref_ordered`). Where
|
||||
// that isn't available (no timeline export, or the ring fell back to plain CUDA slots)
|
||||
// a cursor forces the CPU-synced path: the blend's cross-API ordering is then fence/CPU-
|
||||
// established, sitting between the copy and the encode. That slow path is why cursor
|
||||
// frames USED to be gated out entirely — under gamescope the compositor re-attaches the
|
||||
// live pointer to EVERY frame, and the per-frame CPU syncs (exposed to the running
|
||||
// game's GPU load) capped a 120 fps session near 80 (submit p50 ~10 ms).
|
||||
let cursor_ordered = base_ordered
|
||||
&& captured.cursor.is_some()
|
||||
&& matches!(self.ring[slot].surface, SlotSurface::Vk(_))
|
||||
&& self.vk_blend.as_ref().is_some_and(|vk| vk.ordered_ready());
|
||||
let ordered = base_ordered && (captured.cursor.is_none() || cursor_ordered);
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
// Copy the captured buffer into this slot's input surface before encoding it.
|
||||
@@ -1629,29 +1637,45 @@ impl Encoder for NvencCudaEncoder {
|
||||
|
||||
// Cursor-as-metadata: blend the overlay into this slot's OWNED input surface via the
|
||||
// SPIR-V compute pass (a dispatch over the cursor's rect — never the compositor's
|
||||
// dmabuf). Cursor-bearing frames forced `ordered = false` above, so the CUDA copy has
|
||||
// completed before the Vulkan dispatch and the fence-waited dispatch completes before
|
||||
// the encode below — the cross-API ordering is CPU-established. Any failure degrades to
|
||||
// no cursor, never a dropped frame.
|
||||
// dmabuf). On the `cursor_ordered` path the enqueued copy, the dispatch, and the encode
|
||||
// are ordered on-device through the timeline semaphore (no CPU sync — see the gate
|
||||
// above). Otherwise `ordered` is false: the CUDA copy completed before the Vulkan
|
||||
// dispatch and the fence-waited dispatch completes before the encode below — the
|
||||
// cross-API ordering is CPU-established. Any failure degrades to no cursor, never a
|
||||
// dropped frame (a failed ordered blend leaves the copy→encode stream ordering intact).
|
||||
if let Some(ov) = &captured.cursor {
|
||||
if let (Some(vk), SlotSurface::Vk(vref)) =
|
||||
(self.vk_blend.as_mut(), &self.ring[slot].surface)
|
||||
{
|
||||
if self.cursor_serial != ov.serial {
|
||||
// Quiesces any in-flight ordered blend internally before touching the
|
||||
// staging buffer (bitmap changes are rare — shape flips).
|
||||
vk.upload_cursor(ov.rgba.as_slice(), ov.w, ov.h);
|
||||
self.cursor_serial = ov.serial;
|
||||
}
|
||||
// surfW = content width; the blend derives plane strides from the slot's luma
|
||||
// height. Cursor pixels past the content land in cropped padding rows — harmless.
|
||||
let r = vk.blend_ref(
|
||||
vref,
|
||||
slot_fmt_of(self.buffer_fmt),
|
||||
self.width,
|
||||
ov.w,
|
||||
ov.h,
|
||||
ov.x,
|
||||
ov.y,
|
||||
);
|
||||
let r = if cursor_ordered {
|
||||
vk.blend_ref_ordered(
|
||||
vref,
|
||||
slot_fmt_of(self.buffer_fmt),
|
||||
self.width,
|
||||
ov.w,
|
||||
ov.h,
|
||||
ov.x,
|
||||
ov.y,
|
||||
)
|
||||
} else {
|
||||
vk.blend_ref(
|
||||
vref,
|
||||
slot_fmt_of(self.buffer_fmt),
|
||||
self.width,
|
||||
ov.w,
|
||||
ov.h,
|
||||
ov.x,
|
||||
ov.y,
|
||||
)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
if !self.cursor_blend_warned {
|
||||
self.cursor_blend_warned = true;
|
||||
@@ -1686,7 +1710,9 @@ impl Encoder for NvencCudaEncoder {
|
||||
// stack-local and outlives the synchronous `encode_picture`. The input surface for `slot` was
|
||||
// just filled by the device→device copy — either synchronized (blocking mode) or ordered
|
||||
// before this encode by the session's IO-stream binding (`ordered` — same stream, see the
|
||||
// gate above) — and is not overwritten until this slot is reused POOL submits later, by
|
||||
// gate above; on the `cursor_ordered` path the blend's writes are likewise ordered before
|
||||
// the encode, via the timeline-semaphore wait `blend_ref_ordered` enqueued on that same
|
||||
// stream) — and is not overwritten until this slot is reused POOL submits later, by
|
||||
// which time this encode was polled (POOL ≥ in-flight depth; in ordered mode the poll's
|
||||
// blocking lock additionally proves the enqueued copy completed).
|
||||
unsafe {
|
||||
@@ -2673,6 +2699,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): cursor-bearing frames must KEEP the stream-ordered fast
|
||||
/// path — the gamescope 80-fps-on-a-120-session fix. With the timeline-semaphore blend
|
||||
/// available, `submit` takes `blend_ref_ordered` (the ticket advances by 2 per frame)
|
||||
/// instead of the CPU-synced fence-wait blend, and AUs keep flowing — including across a
|
||||
/// cursor-bitmap change (exercises the upload quiesce) and per-frame position moves.
|
||||
#[test]
|
||||
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.21)"]
|
||||
fn nvenc_cuda_cursor_blend_stream_ordered() {
|
||||
const W: u32 = 1280;
|
||||
const H: u32 = 720;
|
||||
pf_zerocopy::cuda::make_current().expect("shared CUDA context current");
|
||||
// Respect an explicit operator opt-out (or two-thread mode) rather than fail.
|
||||
if !stream_ordered_requested() || async_retrieve_requested() {
|
||||
println!("skipped: stream-ordered submit disabled by env");
|
||||
return;
|
||||
}
|
||||
let mut enc = NvencCudaEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::Nv12,
|
||||
W,
|
||||
H,
|
||||
60,
|
||||
8_000_000,
|
||||
true,
|
||||
8,
|
||||
ChromaFormat::Yuv420,
|
||||
true, // cursor_blend: bring up the Vulkan slot ring + blend
|
||||
)
|
||||
.expect("open NVENC CUDA session");
|
||||
let cursor = |serial: u64, x: i32, y: i32| pf_frame::CursorOverlay {
|
||||
x,
|
||||
y,
|
||||
w: 32,
|
||||
h: 32,
|
||||
rgba: std::sync::Arc::new(vec![0xFF; 32 * 32 * 4]),
|
||||
serial,
|
||||
hot_x: 0,
|
||||
hot_y: 0,
|
||||
visible: true,
|
||||
};
|
||||
let mut aus = 0usize;
|
||||
for i in 0..6u32 {
|
||||
let mut frame = nv12_frame(W, H, i);
|
||||
// Bitmap serial flips at frame 3 (upload quiesce over in-flight ordered blends);
|
||||
// the position moves every frame (push-constant path).
|
||||
frame.cursor = Some(cursor(
|
||||
if i < 3 { 1 } else { 2 },
|
||||
40 + i as i32 * 9,
|
||||
60 + i as i32 * 5,
|
||||
));
|
||||
enc.submit_indexed(&frame, i).expect("submit cursor frame");
|
||||
while enc.poll().expect("poll").is_some() {
|
||||
aus += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(aus, 6, "every cursor frame must deliver an AU");
|
||||
assert!(
|
||||
enc.stream_ordered,
|
||||
"IO-stream binding must arm on a default-env session"
|
||||
);
|
||||
let vk = enc
|
||||
.vk_blend
|
||||
.as_ref()
|
||||
.expect("Vulkan slot blend must come up on an RTX box");
|
||||
assert!(
|
||||
vk.ordered_ready(),
|
||||
"timeline semaphore must export to CUDA on this driver"
|
||||
);
|
||||
assert_eq!(
|
||||
vk.ordered_ticket(),
|
||||
12,
|
||||
"all 6 cursor blends must take the ordered path (2 timeline values each)"
|
||||
);
|
||||
println!(
|
||||
"nvenc_cuda cursor stream-ordered: 6 cursor AUs, ticket={}",
|
||||
vk.ordered_ticket()
|
||||
);
|
||||
}
|
||||
|
||||
/// ON-HARDWARE (RTX box `.21`): the §7 LN3 pipelined-retrieve escalation —
|
||||
/// `set_pipelined(true)` on a live sync session must rebuild it without the IO-stream
|
||||
/// binding, spawn the retrieve thread on the re-open, and keep delivering AUs (the first
|
||||
|
||||
@@ -395,6 +395,9 @@ pub struct PyroWaveEncoder {
|
||||
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
|
||||
/// one packet per AU (the dense MVP shape).
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
wire_budget: crate::pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
frame_count: u64,
|
||||
@@ -653,6 +656,7 @@ impl PyroWaveEncoder {
|
||||
chroma444,
|
||||
frame_budget: budget_for(bitrate, fps),
|
||||
wire_chunk: None,
|
||||
wire_budget: crate::pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
frame_count: 0,
|
||||
@@ -1120,6 +1124,16 @@ impl PyroWaveEncoder {
|
||||
Ok(self.cpu_img.unwrap().2)
|
||||
}
|
||||
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
|
||||
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
@@ -1175,6 +1189,8 @@ impl PyroWaveEncoder {
|
||||
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
|
||||
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
|
||||
// which the next `begin` may implicitly reset.
|
||||
// Resolved before the closure (which borrows `self` mutably for the recording calls).
|
||||
let rate_budget = self.rate_budget();
|
||||
let record_and_submit = (|| -> Result<()> {
|
||||
dev.begin_command_buffer(
|
||||
self.cmd,
|
||||
@@ -1396,7 +1412,7 @@ impl PyroWaveEncoder {
|
||||
],
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: rate_budget,
|
||||
};
|
||||
pw::pyrowave_device_set_command_buffer(
|
||||
self.pw_dev,
|
||||
@@ -1476,6 +1492,10 @@ impl PyroWaveEncoder {
|
||||
// single packet, or the datagram-aligned windowed AU (§4.4).
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.frame_count += 1;
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
|
||||
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
|
||||
count
|
||||
}
|
||||
|
||||
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
|
||||
///
|
||||
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
|
||||
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
|
||||
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
|
||||
/// 1440p/~850 KiB frames that is ×1.2–1.3 — the 2026-07 field report's "Automatic" 407 Mb/s
|
||||
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
|
||||
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
|
||||
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
|
||||
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
|
||||
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
|
||||
/// every codec.
|
||||
pub(crate) struct WireBudget {
|
||||
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
|
||||
scale_x1024: u32,
|
||||
}
|
||||
|
||||
impl WireBudget {
|
||||
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
|
||||
/// converges onto the session's real ratio within ~a second of frames.
|
||||
const PRIOR_X1024: u32 = 1280;
|
||||
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
|
||||
/// re-converges in ~16 frames.
|
||||
const EMA_SHIFT: u32 = 3;
|
||||
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
|
||||
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
|
||||
const MIN_X1024: u32 = 1024;
|
||||
const MAX_X1024: u32 = 2048;
|
||||
|
||||
pub(crate) fn new() -> WireBudget {
|
||||
WireBudget {
|
||||
scale_x1024: Self::PRIOR_X1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
|
||||
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
|
||||
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
|
||||
if bitstream_len == 0 {
|
||||
return;
|
||||
}
|
||||
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
|
||||
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
|
||||
let ema = self.scale_x1024 as i64;
|
||||
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
|
||||
}
|
||||
|
||||
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
|
||||
/// currently-measured inflation.
|
||||
pub(crate) fn deflate(&self, budget: usize) -> usize {
|
||||
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
|
||||
((budget as u64 * 1024) / scale) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
|
||||
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
|
||||
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
|
||||
@@ -259,6 +314,37 @@ mod tests {
|
||||
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
|
||||
}
|
||||
|
||||
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
|
||||
/// deflates the budget by exactly that ratio, and clamps runaway samples.
|
||||
#[test]
|
||||
fn wire_budget_converges_and_deflates() {
|
||||
let mut wb = WireBudget::new();
|
||||
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
|
||||
assert_eq!(wb.deflate(1_024_000), 819_200);
|
||||
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1300);
|
||||
}
|
||||
let b = wb.deflate(1_024_000);
|
||||
let expect = 1_024_000_u64 * 1000 / 1300;
|
||||
assert!(
|
||||
(b as i64 - expect as i64).unsigned_abs() < 8_000,
|
||||
"budget {b} should approach {expect}"
|
||||
);
|
||||
// A dense-ish run (×1.0) walks it back down to no deflation.
|
||||
for _ in 0..64 {
|
||||
wb.observe(1000, 1000);
|
||||
}
|
||||
assert_eq!(wb.deflate(1_024_000), 1_024_000);
|
||||
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
|
||||
for _ in 0..256 {
|
||||
wb.observe(10, 1000);
|
||||
}
|
||||
assert!(wb.deflate(1_024_000) >= 512_000);
|
||||
// …and a zero-length observation is ignored, never a division by zero.
|
||||
wb.observe(0, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_color_bits_sets_range_and_hdr_bits() {
|
||||
let mut bs = vec![0u8; 16];
|
||||
|
||||
@@ -159,6 +159,9 @@ pub struct PyroWaveEncoder {
|
||||
frame_budget: usize,
|
||||
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
|
||||
wire_chunk: Option<usize>,
|
||||
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
|
||||
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
wire_budget: pyrowave_wire::WireBudget,
|
||||
bitstream: Vec<u8>,
|
||||
pending: VecDeque<EncodedFrame>,
|
||||
}
|
||||
@@ -288,6 +291,7 @@ impl PyroWaveEncoder {
|
||||
hdr16,
|
||||
frame_budget,
|
||||
wire_chunk: None,
|
||||
wire_budget: pyrowave_wire::WireBudget::new(),
|
||||
bitstream: Vec::new(),
|
||||
pending: VecDeque::new(),
|
||||
})
|
||||
@@ -441,6 +445,16 @@ impl PyroWaveEncoder {
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
|
||||
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
|
||||
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
|
||||
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
|
||||
fn rate_budget(&self) -> usize {
|
||||
match self.wire_chunk {
|
||||
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
|
||||
None => self.frame_budget,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
|
||||
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
|
||||
// handing null to pyrowave (see the Linux twin).
|
||||
@@ -616,7 +630,7 @@ impl PyroWaveEncoder {
|
||||
sync: std::mem::zeroed(),
|
||||
};
|
||||
let rc = pw::pyrowave_rate_control {
|
||||
maximum_bitstream_size: self.frame_budget,
|
||||
maximum_bitstream_size: self.rate_budget(),
|
||||
};
|
||||
pw_check(
|
||||
pw::pyrowave_encoder_encode_gpu_synchronous(
|
||||
@@ -664,6 +678,10 @@ impl PyroWaveEncoder {
|
||||
}
|
||||
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
|
||||
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
|
||||
if self.wire_chunk.is_some() {
|
||||
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
|
||||
self.wire_budget.observe(raw, au.len());
|
||||
}
|
||||
self.pending.push_back(EncodedFrame {
|
||||
data: au,
|
||||
pts_ns: frame.pts_ns,
|
||||
|
||||
@@ -115,6 +115,14 @@ pub struct HostConfig {
|
||||
/// injected pointer. Default OFF: it forces relative mode, which breaks absolute-pointer titles
|
||||
/// and menus, so it's opt-in per host until validated on-glass.
|
||||
pub gamescope_grab_cursor: bool,
|
||||
/// `PUNKTFUNK_GAMESCOPE_SPLASH` — run the host's built-in splash client inside every bare
|
||||
/// headless gamescope spawn. gamescope only composites (and only then pushes a PipeWire capture
|
||||
/// buffer) when a client paints, and a dedicated Steam launch paints NOTHING
|
||||
/// for the whole Steam bootstrap — so without the splash a fresh spawn's capture starves: format
|
||||
/// negotiated, zero buffers, first-frame timeout, and every retry kills the booting Steam and
|
||||
/// starts over (the "fresh gamescope output never delivers frames" field failure). Default ON;
|
||||
/// explicit-off grammar (`=0` disables, the on-glass A/B + emergency escape hatch).
|
||||
pub gamescope_splash: bool,
|
||||
/// `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
|
||||
/// SIGSEGV → GDM greeter, whose auto-login is once-per-boot, so the box would otherwise need a walk-up
|
||||
@@ -177,6 +185,9 @@ impl HostConfig {
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}),
|
||||
// Default ON, explicit-off grammar: the splash is what makes a fresh bare spawn deliver
|
||||
// its first frames at all; `=0` is the A/B + escape hatch.
|
||||
gamescope_splash: env_on("PUNKTFUNK_GAMESCOPE_SPLASH").unwrap_or(true),
|
||||
recover_session_cmd: val("PUNKTFUNK_RECOVER_SESSION_CMD")
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()),
|
||||
|
||||
@@ -48,6 +48,9 @@ wayland-backend = "0.3"
|
||||
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
|
||||
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
|
||||
bitflags = "2"
|
||||
# The gamescope bare-spawn splash client (gamescope/splash.rs): pure-Rust X11 core protocol (the
|
||||
# same no-libxcb-link stance as pf-capture's XFixes cursor source), no extension features needed.
|
||||
x11rb = { version = "0.13", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
|
||||
|
||||
@@ -77,7 +77,7 @@ pub use session::{session_epoch, try_recover_session};
|
||||
#[path = "vdisplay/routing.rs"]
|
||||
pub(crate) mod routing;
|
||||
pub use routing::{
|
||||
apply_input_env, managed_session_available, restore_managed_session,
|
||||
apply_input_env, managed_session_available, restore_managed_session, restore_takeover_now,
|
||||
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -425,6 +425,16 @@ pub fn effective_topology() -> policy::Topology {
|
||||
#[path = "vdisplay/linux/gamescope.rs"]
|
||||
mod gamescope;
|
||||
|
||||
/// Entry point for the hidden `gamescope-splash` host subcommand: the tiny X11 client every bare
|
||||
/// gamescope spawn backgrounds beside its nested app, so the fresh compositor composites — and its
|
||||
/// PipeWire node delivers frames — from the first second instead of starving the first-frame wait
|
||||
/// while the nested Steam bootstrap paints nothing (see `vdisplay/linux/gamescope/splash.rs`).
|
||||
/// Blocks for the session's lifetime; gamescope's reaper tears it down with the session.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn gamescope_splash_client() -> anyhow::Result<()> {
|
||||
gamescope::splash_run()
|
||||
}
|
||||
|
||||
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
|
||||
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
|
||||
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
|
||||
|
||||
@@ -181,9 +181,14 @@ pub fn panel_off_except(exclude_gdi: &str) -> u32 {
|
||||
acked += set_power(m.hmon, &m.device, POWER_OFF);
|
||||
}
|
||||
if acked == 0 {
|
||||
tracing::debug!(
|
||||
"DDC/CI: no physical panel accepted the DPMS-off command \
|
||||
(no DDC/CI-capable panel besides the virtual display)"
|
||||
// INFO, not debug: the user opted into this axis, so "it did nothing" is an answer they
|
||||
// asked for. The common case is a laptop — internal eDP/LVDS panels have NO DDC/CI at
|
||||
// all (their brightness/power runs over the driver's own channel), so `ddc_power_off`
|
||||
// is structurally a no-op for them (reporter feedback 2026-07-27).
|
||||
tracing::info!(
|
||||
"DDC/CI: no panel accepted the DPMS-off command — the ddc_power_off axis did \
|
||||
nothing on this display set (internal eDP/LVDS panels expose no DDC/CI; external \
|
||||
monitors may have it disabled in the OSD or dropped by a dock/KVM)"
|
||||
);
|
||||
}
|
||||
acked
|
||||
|
||||
@@ -21,6 +21,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
#[path = "gamescope/discovery.rs"]
|
||||
mod discovery;
|
||||
#[path = "gamescope/splash.rs"]
|
||||
mod splash;
|
||||
use discovery::{
|
||||
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node,
|
||||
gamescope_node_present, poll_managed_node, wait_for_node,
|
||||
@@ -29,6 +31,7 @@ pub(crate) use discovery::{
|
||||
game_session_exited, is_available, steam_appid_from_launch, wait_for_steam_game_exit,
|
||||
SteamGameWatch,
|
||||
};
|
||||
pub(crate) use splash::run as splash_run;
|
||||
|
||||
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
||||
/// * `PUNKTFUNK_GAMESCOPE_SESSION=<client>` — host-MANAGE a `gamescope-session-plus` session
|
||||
@@ -68,14 +71,23 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(
|
||||
/// master — live-proven on the Nobara repro VM 2026-07-24).
|
||||
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the
|
||||
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
|
||||
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
|
||||
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
|
||||
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
|
||||
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
|
||||
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
|
||||
/// therefore the one durable trace of the user's switch request.
|
||||
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> =
|
||||
///
|
||||
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
|
||||
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
|
||||
/// in-stream request: the sentinel is a permanent file, so any box whose user has EVER switched
|
||||
/// sessions has one, and comparing against a missing baseline made that ancient write look like
|
||||
/// a live "Switch to Desktop".
|
||||
/// * **`Some(None)`** — baselined while no sentinel existed yet; a later one was created inside the
|
||||
/// session, which IS a request.
|
||||
/// * **`Some(Some(t))`** — baselined at mtime `t`; anything newer is a request.
|
||||
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<Option<std::time::SystemTime>>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
|
||||
@@ -1152,8 +1164,8 @@ const DM_HELPER_PATHS: &[&str] = &[
|
||||
"/usr/lib/punktfunk/pf-dm-helper",
|
||||
];
|
||||
|
||||
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
|
||||
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||
/// Run the packaged DM helper (`stop` | `restore` | `linger`) via pkexec. `false` when the helper
|
||||
/// isn't installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||
fn dm_helper(verb: &str) -> bool {
|
||||
let Some(helper) = DM_HELPER_PATHS
|
||||
.iter()
|
||||
@@ -1169,18 +1181,102 @@ fn dm_helper(verb: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `systemctl` on the SYSTEM bus, **never interactively**. Every privileged verb below runs on the
|
||||
/// stream's own thread — the capture-loss rebuild, or the restore worker — with no way to answer a
|
||||
/// question. Without `--no-ask-password`, systemctl asks polkit for interactive authorization, and
|
||||
/// on a box whose desktop session is still alive (the host is a `--user` unit inside it) polkit
|
||||
/// hands that to the session's agent: a password dialog on the box's OWN screen, which during a
|
||||
/// managed takeover is off or mid-switch. Nobody sees it, nobody answers it, and the call blocks
|
||||
/// while the rebuild budget burns — the takeover then lands after the session it was for already
|
||||
/// ended. `--no-ask-password` turns that into the immediate "interactive authentication required"
|
||||
/// failure the callers are written for, so the pkexec helper (`allow_any`, no agent needed) takes
|
||||
/// over instead of a dialog. Field-suspect in the 0.20.0 Nobara report (intermittent disconnect +
|
||||
/// a screen that never comes back), where the timing is a race against the KDE agent's own death.
|
||||
fn systemctl_system(args: &[&str]) -> bool {
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--no-ask-password").args(args);
|
||||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
|
||||
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
|
||||
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
|
||||
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
|
||||
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
|
||||
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
|
||||
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
|
||||
/// enabled there for the sessionless tests.
|
||||
///
|
||||
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
|
||||
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
|
||||
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
|
||||
/// degrades to attach, which mirrors the box's own session and never stops the DM.
|
||||
fn ensure_host_survives_dm_stop() -> bool {
|
||||
if !host_is_under_user_manager() {
|
||||
return true; // root / a system unit — the DM stop cannot reach us
|
||||
}
|
||||
if linger_enabled() {
|
||||
return true;
|
||||
}
|
||||
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
|
||||
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
|
||||
// helper, whose grant is scoped to the calling uid.
|
||||
let uid = uid_string();
|
||||
let _ = Command::new("loginctl")
|
||||
.args(["--no-ask-password", "enable-linger", &uid])
|
||||
.status();
|
||||
if linger_enabled() || (dm_helper("linger") && linger_enabled()) {
|
||||
tracing::info!(
|
||||
uid,
|
||||
"enabled lingering for this user — the managed takeover stops the display manager, \
|
||||
which ends this login session, and without lingering logind would stop the host \
|
||||
along with it (`loginctl disable-linger` reverts it)"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
|
||||
/// stop take us down)? Read from our own cgroup path.
|
||||
fn host_is_under_user_manager() -> bool {
|
||||
std::fs::read_to_string("/proc/self/cgroup")
|
||||
.as_deref()
|
||||
.map(cgroup_under_user_manager)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
|
||||
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
|
||||
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
|
||||
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
|
||||
/// reaches units the user manager owns.
|
||||
fn cgroup_under_user_manager(cgroup: &str) -> bool {
|
||||
cgroup.contains("user@")
|
||||
}
|
||||
|
||||
/// Our uid as a string — what `loginctl` wants for a user argument.
|
||||
fn uid_string() -> String {
|
||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
||||
unsafe { libc::getuid() }.to_string()
|
||||
}
|
||||
|
||||
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)?
|
||||
fn linger_enabled() -> bool {
|
||||
Command::new("loginctl")
|
||||
.args(["show-user", &uid_string(), "-p", "Linger", "--value"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
|
||||
/// attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("stop")
|
||||
systemctl_system(&["stop", dm]) || dm_helper("stop")
|
||||
}
|
||||
|
||||
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||
@@ -1189,15 +1285,8 @@ fn try_stop_display_manager(dm: &str) -> bool {
|
||||
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
|
||||
/// two steps as root.
|
||||
fn restore_display_manager(dm: &str) -> bool {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", dm])
|
||||
.status();
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["restart", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("restore")
|
||||
let _ = systemctl_system(&["reset-failed", dm]);
|
||||
systemctl_system(&["restart", dm]) || dm_helper("restore")
|
||||
}
|
||||
|
||||
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||
@@ -1223,24 +1312,39 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(path).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream
|
||||
/// "Switch to Desktop") is distinguishable from the switch that led into this session.
|
||||
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
|
||||
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
|
||||
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
|
||||
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
|
||||
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
|
||||
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
|
||||
/// as a live request and pushing the box to the desktop the user never asked for.
|
||||
fn record_session_select_baseline() {
|
||||
*SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(session_select_mtime());
|
||||
}
|
||||
|
||||
/// Did a session-select run inside the managed session since its launch (sentinel newer than the
|
||||
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam
|
||||
/// offers is TO the desktop, so an advanced sentinel reads as that request.
|
||||
/// Did a session-select run inside the managed session since the baseline? Inside a managed game
|
||||
/// session the only switch Steam offers is TO the desktop, so an advanced sentinel reads as that
|
||||
/// request.
|
||||
fn session_select_requested() -> bool {
|
||||
let baseline = *SESSION_SELECT_BASELINE
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
match (baseline, session_select_mtime()) {
|
||||
(Some(base), Some(now)) => now > base,
|
||||
(None, Some(_)) => true, // created during the session
|
||||
sentinel_advanced(baseline, session_select_mtime())
|
||||
}
|
||||
|
||||
/// [`session_select_requested`]'s decision, as a pure function of the two readings (the
|
||||
/// unit-testable core). No baseline ⇒ no request: see [`SESSION_SELECT_BASELINE`] for why a
|
||||
/// missing baseline must not be read as "the sentinel appeared during the session".
|
||||
fn sentinel_advanced(
|
||||
baseline: Option<Option<std::time::SystemTime>>,
|
||||
now: Option<std::time::SystemTime>,
|
||||
) -> bool {
|
||||
match (baseline, now) {
|
||||
(Some(Some(base)), Some(now)) => now > base,
|
||||
(Some(None), Some(_)) => true, // no sentinel at baseline — created during the session
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1392,6 +1496,19 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let dm = dm.expect("!is_none_or ⇒ Some");
|
||||
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
|
||||
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
|
||||
// box's display manager down and nobody left to bring it back. Degrading to attach is
|
||||
// strictly better than a black screen that needs a VT to recover.
|
||||
if !ensure_host_survives_dm_stop() {
|
||||
bail!(
|
||||
"stopping {dm} ends this user's last login session, and without lingering logind \
|
||||
would stop the user manager — and this host with it — about 10s later, leaving \
|
||||
the box with no display manager and nothing to restore it; enabling lingering \
|
||||
failed, so the managed takeover is unavailable (run `sudo loginctl enable-linger \
|
||||
$USER` once, as the setup docs ask, then reconnect)"
|
||||
);
|
||||
}
|
||||
if !try_stop_display_manager(&dm) {
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||
@@ -1405,6 +1522,11 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
%dm,
|
||||
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
|
||||
);
|
||||
// Baseline the switch sentinel HERE, not just at a successful launch: setting STOPPED_DM
|
||||
// is what arms the honor gate, so from this instant an unbaselined sentinel would read as
|
||||
// an in-stream "Switch to Desktop" — including the write from the switch that just brought
|
||||
// the box INTO game mode. A successful launch re-baselines (tighter still).
|
||||
record_session_select_baseline();
|
||||
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
|
||||
}
|
||||
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
|
||||
@@ -1564,21 +1686,7 @@ fn restore_delay() -> Option<Duration> {
|
||||
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
|
||||
/// box). Idempotent / safe to call on every session end.
|
||||
pub fn schedule_restore_tv_session() {
|
||||
let nothing_to_restore = STOPPED_AUTOLOGIN
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|
||||
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
|
||||
// A managed session that took nothing over (started beside a live desktop — e.g. a client
|
||||
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
|
||||
// was ORPHANED forever after disconnect ("closing the app does not end the session",
|
||||
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|
||||
&& MANAGED_SESSION
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_none();
|
||||
if nothing_to_restore {
|
||||
if !takeover_live() {
|
||||
return; // nothing was taken over → nothing to restore (also the non-managed path)
|
||||
}
|
||||
match restore_delay() {
|
||||
@@ -1601,6 +1709,48 @@ pub fn schedule_restore_tv_session() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is anything of the box's own session ours right now — an autologin unit we stopped, a stopped
|
||||
/// display manager, a SteamOS target we re-pointed, or a managed session we launched beside a live
|
||||
/// desktop? The precondition for every restore path.
|
||||
fn takeover_live() -> bool {
|
||||
!STOPPED_AUTOLOGIN
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_empty()
|
||||
|| *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|
||||
|| STOPPED_DM
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
// A managed session that took nothing over (started beside a live desktop — e.g. a client
|
||||
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
|
||||
// was ORPHANED forever after disconnect ("closing the app does not end the session",
|
||||
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|
||||
|| MANAGED_SESSION
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Give the box its own session back **now**, synchronously — the host is going away (SIGTERM from
|
||||
/// `systemctl --user stop`/`restart`, a package update, Ctrl-C) and a live takeover must not
|
||||
/// outlive it. On a DM-flavor takeover the display manager is STOPPED: nothing else on the box
|
||||
/// will ever restart it, and the persisted crash-restore state lives in `$XDG_RUNTIME_DIR`, which
|
||||
/// logind removes along with the user manager — so not even the next host start can heal it. The
|
||||
/// box would stay dark until someone reached a VT.
|
||||
///
|
||||
/// Deliberately ignores the keep-alive policy that [`schedule_restore_tv_session`] honors:
|
||||
/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host
|
||||
/// that would serve them is exiting. No-op when nothing was taken over.
|
||||
pub fn restore_takeover_now() {
|
||||
if !takeover_live() {
|
||||
return;
|
||||
}
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
|
||||
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
|
||||
do_restore_tv_session();
|
||||
}
|
||||
|
||||
/// Does any DRM connector report a physically `connected` display? Scans
|
||||
/// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a
|
||||
/// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A
|
||||
@@ -1963,11 +2113,6 @@ pub fn ei_socket_file() -> std::path::PathBuf {
|
||||
crate::with_env_lock(pf_paths::gamescope_ei_socket_file)
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-silent` inserted so
|
||||
/// the game is the gamescope focus with no Steam client window to navigate
|
||||
/// (`design/gamemode-and-dedicated-sessions.md` §5.3). Operator-typed custom commands and non-Steam
|
||||
/// launches are returned unchanged. Idempotent (never double-inserts `-silent`). Pure + unit-tested.
|
||||
/// Does this resolved launch command start Steam (`steam … steam://…`)? Such a launch needs Steam's
|
||||
/// single instance free before a dedicated spawn (B1). Pure + unit-tested.
|
||||
fn is_steam_launch(cmd: &str) -> bool {
|
||||
@@ -1975,12 +2120,21 @@ fn is_steam_launch(cmd: &str) -> bool {
|
||||
it.next() == Some("steam") && cmd.contains("steam://")
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
|
||||
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
|
||||
/// around (it's what SteamOS/Bazzite game mode runs): the boot shows the gamepad UI instead of the
|
||||
/// desktop Steam client window (field report 2026-07-27: the desktop UI flashing through the
|
||||
/// stream "looks bad"), and gamescope's focus rules already prefer the game window over the Steam
|
||||
/// UI appid, which is what the previous `-silent` shaping was working around. Operator-typed
|
||||
/// custom commands and non-Steam launches are returned unchanged. Idempotent (never
|
||||
/// double-inserts). Pure + unit-tested.
|
||||
fn shape_dedicated_command(app: &str) -> String {
|
||||
let mut it = app.split_whitespace();
|
||||
if it.next() == Some("steam") {
|
||||
let rest: Vec<&str> = it.collect();
|
||||
if !rest.contains(&"-silent") && rest.iter().any(|t| t.starts_with("steam://")) {
|
||||
return format!("steam -silent {}", rest.join(" "));
|
||||
if !rest.contains(&"-gamepadui") && rest.iter().any(|t| t.starts_with("steam://")) {
|
||||
return format!("steam -gamepadui {}", rest.join(" "));
|
||||
}
|
||||
}
|
||||
app.to_string()
|
||||
@@ -2016,7 +2170,11 @@ fn add_bare_gamescope_args(
|
||||
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
|
||||
/// stdout/stderr go to `log` (this spawn's per-instance log, A5). The app is launched through a tiny
|
||||
/// shell wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`ei_socket_file`]
|
||||
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||
/// so the input injector can connect to gamescope's EIS server from outside — and (unless
|
||||
/// `PUNKTFUNK_GAMESCOPE_SPLASH=0`) backgrounds the host's splash client first, so the fresh
|
||||
/// compositor has a painting window from the first second: gamescope pushes capture buffers only
|
||||
/// when it composites, and a nested Steam bootstrap paints nothing until the gamepad UI's first
|
||||
/// frame — far longer than any first-frame budget (see `gamescope/splash.rs`).
|
||||
fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> Result<Child> {
|
||||
// A non-empty per-session command (set via `set_launch_command`) wins; else the
|
||||
// `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps
|
||||
@@ -2033,8 +2191,9 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
||||
// cursor-grab flag below.
|
||||
let game_launch = app.is_some();
|
||||
let app = app.unwrap_or_else(|| "sleep infinity".to_string());
|
||||
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-silent` so the game is the
|
||||
// gamescope focus with no Steam client window to navigate.
|
||||
// Dedicated-launch command shaping (Part B): a Steam URI runs with `-gamepadui` so the nested
|
||||
// Steam is Big Picture — the identity gamescope's `--steam` mode is built around — instead of
|
||||
// the desktop client window.
|
||||
let app = shape_dedicated_command(&app);
|
||||
let relay = ei_socket_file();
|
||||
let _ = std::fs::remove_file(&relay); // stale socket path from a previous session
|
||||
@@ -2047,29 +2206,37 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
||||
// cursor, which some FPS titles never signal over the injected pointer — grabbing fixes mouselook.
|
||||
// Default OFF (it forces relative mode, which would break absolute-pointer games/menus).
|
||||
let grab_cursor = game_launch && pf_host_config::config().gamescope_grab_cursor;
|
||||
// The splash client (see `gamescope/splash.rs`): without a painting client gamescope pushes NO
|
||||
// capture buffers, and a nested Steam bootstrap paints nothing for far longer than any
|
||||
// first-frame budget — so every bare spawn backgrounds the host's own splash beside the nested
|
||||
// app. Skipped only via the PUNKTFUNK_GAMESCOPE_SPLASH=0 escape hatch (or if the host can't
|
||||
// name its own executable, where the old starve-prone behaviour is still better than no spawn).
|
||||
let splash_exe = pf_host_config::config()
|
||||
.gamescope_splash
|
||||
.then(std::env::current_exe)
|
||||
.and_then(|r| {
|
||||
r.map_err(|e| tracing::warn!(error = %e, "gamescope: current_exe failed — no splash"))
|
||||
.ok()
|
||||
});
|
||||
let mut cmd = Command::new("gamescope");
|
||||
add_bare_gamescope_args(&mut cmd, w, h, hz, steam_mode, grab_cursor);
|
||||
cmd.args([
|
||||
"sh",
|
||||
"-c",
|
||||
&format!(
|
||||
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
|
||||
relay.display()
|
||||
),
|
||||
"sh",
|
||||
])
|
||||
.args(app.split_whitespace())
|
||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
|
||||
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
|
||||
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
|
||||
// makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before
|
||||
// its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the
|
||||
// bug because it predates any login's env import). gamescope exports its own DISPLAY /
|
||||
// GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY");
|
||||
let script = nested_wrapper_script(&relay, splash_exe.is_some());
|
||||
cmd.args(["sh", "-c", &script, "sh"]);
|
||||
if let Some(exe) = &splash_exe {
|
||||
cmd.arg(exe);
|
||||
}
|
||||
cmd.args(app.split_whitespace())
|
||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
|
||||
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
|
||||
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
|
||||
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
|
||||
// makes gamescope 3.16 exit at startup with "Failed to connect to wayland socket" before
|
||||
// its PipeWire node ever appears (observed 2026-07-14; the boot-started host never saw the
|
||||
// bug because it predates any login's env import). gamescope exports its own DISPLAY /
|
||||
// GAMESCOPE_WAYLAND_DISPLAY to the nested app, so the child loses nothing.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY");
|
||||
if let Ok(logf) = std::fs::File::create(log) {
|
||||
if let Ok(log2) = logf.try_clone() {
|
||||
cmd.stdout(Stdio::from(logf)).stderr(Stdio::from(log2));
|
||||
@@ -2077,11 +2244,34 @@ fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>, log: &std::path::Path) -> R
|
||||
} else {
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
}
|
||||
tracing::info!(w, h, hz, steam_mode, %app, log = %log.display(), "spawning gamescope (headless)");
|
||||
tracing::info!(
|
||||
w, h, hz, steam_mode,
|
||||
splash = splash_exe.is_some(),
|
||||
%app,
|
||||
log = %log.display(),
|
||||
"spawning gamescope (headless)"
|
||||
);
|
||||
cmd.spawn()
|
||||
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
||||
}
|
||||
|
||||
/// The nested-command wrapper script for a bare spawn: relay gamescope's `LIBEI_SOCKET` to the
|
||||
/// injector's file, optionally background the splash client (`"$1"` is the host executable — passed
|
||||
/// as an argv so its path never needs shell-escaping), then exec the real app. Pure + unit-tested.
|
||||
fn nested_wrapper_script(relay: &std::path::Path, with_splash: bool) -> String {
|
||||
if with_splash {
|
||||
format!(
|
||||
"printf %s \"$LIBEI_SOCKET\" > '{}'; \"$1\" gamescope-splash & shift; exec \"$@\"",
|
||||
relay.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"printf %s \"$LIBEI_SOCKET\" > '{}'; exec \"$@\"",
|
||||
relay.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the spawned gamescope process (and its per-instance log, A5); killing it tears the virtual
|
||||
/// output down.
|
||||
struct GamescopeProc {
|
||||
@@ -2104,10 +2294,66 @@ impl Drop for GamescopeProc {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
|
||||
dm_survives_masked_unit, is_steam_launch, shape_dedicated_command,
|
||||
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
|
||||
display_manager_unit_under, dm_survives_masked_unit, is_steam_launch,
|
||||
nested_wrapper_script, sentinel_advanced, shape_dedicated_command,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn user_manager_lifetime_detection() {
|
||||
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
|
||||
// login session the DM stop ends — this is the case that needs lingering.
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
|
||||
));
|
||||
assert!(cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
|
||||
));
|
||||
// A system unit outlives every session — the DM stop cannot reach it.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/system.slice/punktfunk-host.service\n"
|
||||
));
|
||||
// Started from a login shell: owned by the session scope, not the user manager.
|
||||
assert!(!cgroup_under_user_manager(
|
||||
"0::/user.slice/user-1000.slice/session-2.scope\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_select_sentinel_needs_a_baseline() {
|
||||
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
|
||||
let t1 = t0 + std::time::Duration::from_secs(1);
|
||||
// Never baselined: the sentinel is a permanent file, so a box whose user EVER switched
|
||||
// sessions has one — that ancient write is not a live "Switch to Desktop" request. This is
|
||||
// the case that pushed a Nobara box to the desktop after a failed managed launch.
|
||||
assert!(!sentinel_advanced(None, Some(t0)));
|
||||
assert!(!sentinel_advanced(None, None));
|
||||
// Baselined with no sentinel yet, then one appeared inside the session: a real request.
|
||||
assert!(sentinel_advanced(Some(None), Some(t0)));
|
||||
assert!(!sentinel_advanced(Some(None), None));
|
||||
// Baselined at an mtime: only a NEWER one is the user's in-stream switch. The write that
|
||||
// brought the box into game mode is the baseline itself, so it reads as no request.
|
||||
assert!(sentinel_advanced(Some(Some(t0)), Some(t1)));
|
||||
assert!(!sentinel_advanced(Some(Some(t0)), Some(t0)));
|
||||
assert!(!sentinel_advanced(Some(Some(t1)), Some(t0)));
|
||||
assert!(!sentinel_advanced(Some(Some(t0)), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_wrapper_script_shapes() {
|
||||
let relay = std::path::Path::new("/run/user/1000/pf-ei");
|
||||
// Plain: relay + exec, no splash machinery.
|
||||
let plain = nested_wrapper_script(relay, false);
|
||||
assert!(plain.contains("/run/user/1000/pf-ei"));
|
||||
assert!(plain.ends_with("exec \"$@\""));
|
||||
assert!(!plain.contains("gamescope-splash"));
|
||||
// Splash: `"$1"` is the host exe (an argv, never shell-interpolated), backgrounded and
|
||||
// shifted away so `exec "$@"` still runs the untouched app tokens.
|
||||
let splash = nested_wrapper_script(relay, true);
|
||||
assert!(splash.contains("\"$1\" gamescope-splash &"));
|
||||
assert!(splash.contains("shift; exec \"$@\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_manager_flavor_detection() {
|
||||
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
|
||||
@@ -2169,15 +2415,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dedicated_command_shaping() {
|
||||
// Steam URI → -silent inserted so the game is the gamescope focus.
|
||||
// Steam URI → -gamepadui inserted so the nested Steam is Big Picture (not the desktop UI).
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam steam://rungameid/570"),
|
||||
"steam -silent steam://rungameid/570"
|
||||
"steam -gamepadui steam://rungameid/570"
|
||||
);
|
||||
// Idempotent: an already-silent command is left alone.
|
||||
// Idempotent: an already-gamepadui command is left alone.
|
||||
assert_eq!(
|
||||
shape_dedicated_command("steam -silent steam://rungameid/570"),
|
||||
"steam -silent steam://rungameid/570"
|
||||
shape_dedicated_command("steam -gamepadui steam://rungameid/570"),
|
||||
"steam -gamepadui steam://rungameid/570"
|
||||
);
|
||||
// Non-Steam launches and operator custom commands are untouched.
|
||||
assert_eq!(shape_dedicated_command("vkcube"), "vkcube");
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The bare-spawn **splash client** — the reason a fresh headless gamescope delivers frames at all.
|
||||
//!
|
||||
//! gamescope only composites (and only on a composite pushes a buffer to its PipeWire capture
|
||||
//! node) when a client paints. A dedicated Steam launch paints NOTHING for the whole Steam
|
||||
//! bootstrap (no window maps until the gamepad UI's first frame) — so a fresh
|
||||
//! spawn's capture negotiated a format, reached `Streaming`, and then starved: zero buffers, the
|
||||
//! 10 s first-frame timeout, teardown — and every native-plane retry killed the half-booted Steam
|
||||
//! and started from zero, while the GameStream plane died on its single wait (the "fresh gamescope
|
||||
//! output never delivers frames" field failure; root-caused on-box 2026-07-27 with a raw pw_stream
|
||||
//! probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately).
|
||||
//!
|
||||
//! This client runs INSIDE the spawned gamescope (the nested-command wrapper backgrounds it before
|
||||
//! exec'ing the real app) and guarantees damage from the first second: a dark window with a subtle
|
||||
//! breathing bar painted at ~2.5 Hz. Two gamescope focus facts, both live-proven on c31743d:
|
||||
//!
|
||||
//! * In `--steam` mode gamescope composites ONLY windows whose Steam appid is listed in the root
|
||||
//! window's `GAMESCOPECTRL_BASELAYER_APPID` property (a plain mapped+painting window — even
|
||||
//! vkcube — gets zero composites). So the splash declares itself as the Steam client UI
|
||||
//! (`STEAM_GAME=769`) and seeds the baselayer with that appid iff nobody has written it yet.
|
||||
//! When Steam finishes booting it rewrites the baselayer as part of launching the game, which
|
||||
//! hands composite focus to the real game window with no action on our side (verified: 2.5 Hz
|
||||
//! splash cadence → 60 Hz game cadence on the same capture stream, no gap).
|
||||
//! * In plain (non-`--steam`) mode any mapped painting window composites; the atoms are inert.
|
||||
//!
|
||||
//! The splash never exits on its own while the session lives: after the game takes focus it keeps
|
||||
//! painting unfocused (damage on an unfocused window schedules no composite — harmless), so if the
|
||||
//! game's window briefly goes away (Steam updater closed, level-load window swap) and gamescope
|
||||
//! re-focuses the splash, liveness returns instead of a frozen last frame. It dies with the session:
|
||||
//! gamescope's reaper kills it at teardown, and any X error (the connection dropping) exits cleanly.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::time::Duration;
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{
|
||||
AtomEnum, ChangeGCAux, ConnectionExt, CreateGCAux, CreateWindowAux, PropMode, Rectangle,
|
||||
WindowClass,
|
||||
};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
use x11rb::wrapper::ConnectionExt as _;
|
||||
|
||||
/// The Steam client UI's appid — the identity the splash borrows so `--steam`-mode gamescope
|
||||
/// treats it as focusable before (and beneath) any real game.
|
||||
const STEAM_UI_APPID: u32 = 769;
|
||||
|
||||
/// Window/bar palette: near-black background, greys the bar breathes through.
|
||||
const BG: u32 = 0x0d0d0d;
|
||||
|
||||
/// How often the splash repaints (each paint is the damage that makes gamescope push a capture
|
||||
/// buffer). 400 ms ≈ 2.5 fps — far inside every first-frame budget, invisible in CPU terms.
|
||||
const TICK: Duration = Duration::from_millis(400);
|
||||
|
||||
/// Entry point for the hidden `gamescope-splash` host subcommand. Blocks for the session's
|
||||
/// lifetime; returns (or errors) only when the X connection is gone or never came up.
|
||||
pub(crate) fn run() -> Result<()> {
|
||||
// gamescope execs its nested command only once Xwayland is ready and DISPLAY is set, so the
|
||||
// first attempt normally lands — the retry covers a slow Xwayland under driver-init load.
|
||||
let (conn, screen_num) = connect_with_retry()?;
|
||||
let screen = &conn.setup().roots[screen_num];
|
||||
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
|
||||
let win = conn.generate_id()?;
|
||||
conn.create_window(
|
||||
x11rb::COPY_DEPTH_FROM_PARENT,
|
||||
win,
|
||||
screen.root,
|
||||
0,
|
||||
0,
|
||||
w,
|
||||
h,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
screen.root_visual,
|
||||
&CreateWindowAux::new().background_pixel(BG),
|
||||
)?;
|
||||
conn.change_property8(
|
||||
PropMode::REPLACE,
|
||||
win,
|
||||
AtomEnum::WM_NAME,
|
||||
AtomEnum::STRING,
|
||||
b"Punktfunk",
|
||||
)?;
|
||||
// STEAM_GAME on the window + GAMESCOPECTRL_BASELAYER_APPID on the root — the pair that makes a
|
||||
// `--steam` gamescope composite us. Seed the baselayer only while unset: once Steam is up it
|
||||
// owns that property (its rewrite at game launch is exactly the handover we want).
|
||||
let steam_game = conn.intern_atom(false, b"STEAM_GAME")?.reply()?.atom;
|
||||
conn.change_property32(
|
||||
PropMode::REPLACE,
|
||||
win,
|
||||
steam_game,
|
||||
AtomEnum::CARDINAL,
|
||||
&[STEAM_UI_APPID],
|
||||
)?;
|
||||
let baselayer = conn
|
||||
.intern_atom(false, b"GAMESCOPECTRL_BASELAYER_APPID")?
|
||||
.reply()?
|
||||
.atom;
|
||||
let existing = conn
|
||||
.get_property(false, screen.root, baselayer, AtomEnum::CARDINAL, 0, 4)?
|
||||
.reply()?;
|
||||
if existing.value_len == 0 {
|
||||
conn.change_property32(
|
||||
PropMode::REPLACE,
|
||||
screen.root,
|
||||
baselayer,
|
||||
AtomEnum::CARDINAL,
|
||||
&[STEAM_UI_APPID],
|
||||
)?;
|
||||
}
|
||||
conn.map_window(win)?;
|
||||
let gc = conn.generate_id()?;
|
||||
conn.create_gc(gc, win, &CreateGCAux::new().foreground(BG))?;
|
||||
conn.flush()?;
|
||||
tracing::info!(
|
||||
w,
|
||||
h,
|
||||
seeded_baselayer = existing.value_len == 0,
|
||||
"gamescope splash: mapped"
|
||||
);
|
||||
|
||||
// The breathing bar. Server-side fills generate the damage; the background pixel handles
|
||||
// exposures, so no event loop is needed. Any request failing = the session (or its Xwayland)
|
||||
// is gone = a clean exit.
|
||||
let bar_w = 120i16;
|
||||
let bar_h = 8i16;
|
||||
let bar = Rectangle {
|
||||
x: (w as i16) / 2 - bar_w / 2,
|
||||
y: (h as i16) / 2 - bar_h / 2,
|
||||
width: bar_w as u16,
|
||||
height: bar_h as u16,
|
||||
};
|
||||
let mut t: u32 = 0;
|
||||
loop {
|
||||
// 8-step triangle pulse through dark greys (0x1a…0x40 per channel).
|
||||
let phase = (t % 8) as i32;
|
||||
let tri = if phase < 4 { phase } else { 8 - phase };
|
||||
let grey = 0x1a + 0x0c * tri as u32;
|
||||
let colour = (grey << 16) | (grey << 8) | grey;
|
||||
conn.change_gc(gc, &ChangeGCAux::new().foreground(colour))?;
|
||||
conn.poly_fill_rectangle(win, gc, &[bar])?;
|
||||
conn.flush()
|
||||
.context("gamescope splash: X connection lost")?;
|
||||
t = t.wrapping_add(1);
|
||||
std::thread::sleep(TICK);
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the session's `DISPLAY`, retrying briefly — gamescope sets the variable before
|
||||
/// exec'ing the nested command, but a slow Xwayland under cold driver init gets a grace window.
|
||||
fn connect_with_retry() -> Result<(RustConnection, usize)> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
match x11rb::connect(None) {
|
||||
Ok(ok) => return Ok(ok),
|
||||
Err(e) if std::time::Instant::now() >= deadline => {
|
||||
return Err(e).context("gamescope splash: could not connect to the session DISPLAY")
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(200)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,6 +268,18 @@ pub fn restore_takeover_on_startup() {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_takeover_on_startup() {}
|
||||
|
||||
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
|
||||
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
|
||||
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
|
||||
/// to restart it. No-op when nothing was taken over.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn restore_takeover_now() {
|
||||
gamescope::restore_takeover_now();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn restore_takeover_now() {}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -865,11 +865,21 @@ impl VirtualDisplayManager {
|
||||
reasserts = fighting,
|
||||
"exclusive topology stable again — no non-managed display active"
|
||||
);
|
||||
// Close the churn window early — descriptor-following resumes now
|
||||
// instead of waiting out the hold's self-expiry.
|
||||
pf_win_display::topology_churn::release();
|
||||
}
|
||||
fighting = 0;
|
||||
continue;
|
||||
}
|
||||
fighting += 1;
|
||||
// Announce the churn BEFORE evicting: every descriptor the capturer's poller
|
||||
// samples from here until "stable again" (or self-expiry) is potentially the
|
||||
// TRANSIENT eviction state — acting on it would recreate the ring at a mode
|
||||
// the recovery chain is about to undo (the field hdr=true→false→true double
|
||||
// recreate). Window = watchdog interval + recovery/debounce margin, refreshed
|
||||
// every fighting round.
|
||||
pf_win_display::topology_churn::hold(interval + Duration::from_secs(3));
|
||||
match fighting {
|
||||
1..=3 => tracing::warn!(
|
||||
survivors,
|
||||
|
||||
@@ -156,15 +156,27 @@ pub fn summarize(events: &[DisplayEvent]) -> String {
|
||||
out.join(", ")
|
||||
}
|
||||
|
||||
/// The prime suspects for link-probe disturbances, from the cached inventory: external physical
|
||||
/// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor).
|
||||
/// Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
|
||||
pub fn connected_inactive_externals() -> Vec<String> {
|
||||
/// The prime suspects for link-probe/dark-head disturbances, from the cached inventory: PHYSICAL
|
||||
/// displays — external connectors AND internal panels — that are CONNECTED but not part of the
|
||||
/// desktop. External = the classic standby TV / input-switched monitor; internal = the laptop
|
||||
/// panel the exclusive isolate deactivated, whose driver-level servicing produces the identical
|
||||
/// ~2 s metronome on hybrid laptops (field A/B 2026-07-27: reporter's Legion, exclusive
|
||||
/// 16.3 stalls/min vs primary 0 — the old external-only filter reported `none` there and steered
|
||||
/// the diagnosis AWAY from the real cause for hours). Virtual/indirect targets stay excluded
|
||||
/// (precision rule). Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
|
||||
pub fn connected_inactive_physicals() -> Vec<String> {
|
||||
let st = state().lock().unwrap();
|
||||
st.inventory
|
||||
.iter()
|
||||
.filter(|t| t.external_physical && !t.active)
|
||||
.map(|t| format!("{} ({})", t.friendly, t.tech))
|
||||
.filter(|t| (t.external_physical || t.internal_panel) && !t.active)
|
||||
.map(|t| {
|
||||
let name = if t.friendly.is_empty() && t.internal_panel {
|
||||
"laptop panel"
|
||||
} else {
|
||||
&t.friendly
|
||||
};
|
||||
format!("{} ({})", name, t.tech)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ pub mod display_events;
|
||||
mod input_desktop;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod monitor_devnode;
|
||||
/// Cross-crate "topology churn in flight" latch (pure std — no Windows surface, so unconditionally
|
||||
/// compiled and unit-tested on every platform).
|
||||
pub mod topology_churn;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod win_display;
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Cross-crate "topology churn in flight" latch (stall-immunity program): the exclusive-topology
|
||||
//! reassert watchdog (pf-vdisplay's manager) announces that it is evicting/restoring displays, and
|
||||
//! the IDD-push capturer's descriptor follower (pf-capture) defers acting on descriptor changes
|
||||
//! while the window is open.
|
||||
//!
|
||||
//! Why: the reassert's forced re-commit transiently bounces the virtual display's mode — the
|
||||
//! descriptor poller can sample the EVICTION state (field log 2026-07-27 10:30:44Z: `hdr=true` →
|
||||
//! `hdr=false` → recreate → recovery restores `hdr=true` → second recreate). Every descriptor
|
||||
//! sampled inside the window is potentially that transient, and acting on it recreates the ring at
|
||||
//! a mode the recovery chain is about to undo. The deliberate recovery rebuild
|
||||
//! (`recreate_ring_in_place`, keyed off the reassert generation) is NOT affected — only the
|
||||
//! passive descriptor-following is.
|
||||
//!
|
||||
//! Deadline semantics, not a flag: a `hold()` self-expires, so a holder that dies mid-churn (or a
|
||||
//! release lost to a teardown race) can never wedge descriptor-following off forever. `release()`
|
||||
//! just expires the deadline early on the watchdog's "stable again" observation.
|
||||
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
OnceLock,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Deadline as milliseconds on the process-local [`clock`]; `0` = no hold.
|
||||
static HOLD_UNTIL_MS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Process-local monotonic epoch (an `Instant` cannot live in an atomic).
|
||||
fn clock() -> u64 {
|
||||
static EPOCH: OnceLock<Instant> = OnceLock::new();
|
||||
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// Open (or extend) the churn window for `dur` from now. `fetch_max` so overlapping holders — a
|
||||
/// reassert round racing a slot transition — never shorten each other's window.
|
||||
pub fn hold(dur: Duration) {
|
||||
let until = clock().saturating_add(dur.as_millis() as u64);
|
||||
HOLD_UNTIL_MS.fetch_max(until, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Expire the window now (the watchdog observed a stable topology again).
|
||||
pub fn release() {
|
||||
HOLD_UNTIL_MS.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Is a churn window open? Cheap enough for a per-frame path (one atomic load + a monotonic read).
|
||||
#[must_use]
|
||||
pub fn held() -> bool {
|
||||
clock() < HOLD_UNTIL_MS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The latch's contract end-to-end: closed at rest, open after `hold`, extended by the longer
|
||||
/// of two overlapping holds, closed by `release`, and self-expiring without one.
|
||||
#[test]
|
||||
fn hold_release_expire() {
|
||||
release();
|
||||
assert!(!held());
|
||||
hold(Duration::from_secs(60));
|
||||
assert!(held());
|
||||
// A shorter overlapping hold must not shorten the window.
|
||||
hold(Duration::from_millis(1));
|
||||
assert!(held());
|
||||
release();
|
||||
assert!(!held());
|
||||
// Self-expiry: a millisecond-scale hold lapses on its own.
|
||||
hold(Duration::from_millis(30));
|
||||
assert!(held());
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
assert!(!held());
|
||||
}
|
||||
}
|
||||
@@ -844,15 +844,21 @@ pub unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option<u32> {
|
||||
}
|
||||
|
||||
/// One CONNECTED display target from a full (`QDC_ALL_PATHS`) CCD sweep — the disturbance-
|
||||
/// attribution inventory. `external_physical` is the load-bearing bit: a standby TV/monitor on a
|
||||
/// real connector is the prime suspect for the periodic link-probe stutter class, while internal
|
||||
/// panels and indirect/virtual targets (our own IDD included) are not.
|
||||
/// attribution inventory. `external_physical` and `internal_panel` are the load-bearing bits: a
|
||||
/// standby TV/monitor on a real connector is the classic suspect for the periodic link-probe
|
||||
/// stutter class, and a laptop panel the exclusive isolate DEACTIVATED is the hybrid-laptop
|
||||
/// variant of the same disturbance (field A/B 2026-07-27: dark-but-connected eDP head on the
|
||||
/// iGPU → ~2 s stall metronome; `topology: primary` → zero) — only indirect/virtual targets
|
||||
/// (our own IDD included) can never be suspects.
|
||||
pub struct TargetInventory {
|
||||
pub target_id: u32,
|
||||
/// Whether any active path drives this target (part of the desktop right now).
|
||||
pub active: bool,
|
||||
/// External physical connector (HDMI/DP/DVI/…): candidate for standby link-probe churn.
|
||||
pub external_physical: bool,
|
||||
/// Internal panel (eDP/LVDS/embedded): candidate for dark-head servicing churn when
|
||||
/// connected-but-inactive (the exclusive isolate on a laptop creates exactly that state).
|
||||
pub internal_panel: bool,
|
||||
/// Short connector label for logs (`"HDMI"`, `"DisplayPort"`, `"internal-panel"`, …).
|
||||
pub tech: &'static str,
|
||||
/// The monitor's friendly name (`"LG TV SSCR2"`); empty when the EDID carries none.
|
||||
@@ -953,6 +959,7 @@ pub unsafe fn target_inventory() -> Vec<TargetInventory> {
|
||||
target_id: t.id,
|
||||
active: active.contains(&key),
|
||||
external_physical,
|
||||
internal_panel: tech == "internal-panel",
|
||||
tech,
|
||||
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
||||
monitor_device_path: utf16z_str(&req.monitorDevicePath),
|
||||
|
||||
@@ -262,6 +262,15 @@ unsafe fn copy_async(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
||||
ck(cuMemcpy2DAsync_v2(copy, copy_stream()), what)
|
||||
}
|
||||
|
||||
/// Block until everything enqueued on THIS THREAD's copy stream completed — the shared tail of
|
||||
/// the multi-plane blocking copies (stream FIFO: one sync after the last enqueue covers every
|
||||
/// plane, where the per-plane `copy_blocking` paid one exposed CPU wait EACH — and under a
|
||||
/// game's GPU load each exposed wait eats scheduling latency). The shared context must be
|
||||
/// current.
|
||||
unsafe fn sync_copy_stream() -> Result<()> {
|
||||
ck(cuStreamSynchronize(copy_stream()), "cuStreamSynchronize")
|
||||
}
|
||||
|
||||
/// `copy_blocking` when `sync`, else `copy_async` — the shared tail of the public `copy_*_to_device`
|
||||
/// helpers, whose `sync: false` mode carries `copy_async`'s source-lifetime contract.
|
||||
unsafe fn copy_issue(copy: &CUDA_MEMCPY2D, what: &str, sync: bool) -> Result<()> {
|
||||
@@ -985,17 +994,24 @@ pub fn copy_nv12_to_device(
|
||||
Height: h / 2,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: two unsafe `copy_issue` device→device copies; the caller must have the shared
|
||||
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
||||
// enqueue. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
||||
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
||||
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
||||
// `(w/2)*2`×`h/2`, each within its planes; `sync: false` shifts the source-lifetime obligation
|
||||
// to the caller (documented above). Wrappers → live table.
|
||||
// SAFETY: two unsafe `copy_async` device→device enqueues + an optional stream sync; the
|
||||
// caller must have the shared context current (documented). `&y`/`&uv` are live local
|
||||
// `CUDA_MEMCPY2D`s outliving each enqueue. All four device pointers are valid:
|
||||
// `src.ptr`/`src_uv_ptr` come from a live NV12 `DeviceBuffer` (its `.uv` presence was
|
||||
// checked via `ok_or_else`), `y_dst`/`uv_dst` are the caller's live NVENC surface planes;
|
||||
// the luma copy is `w`×`h`, the chroma copy `(w/2)*2`×`h/2`, each within its planes. With
|
||||
// `sync` the single trailing stream sync covers both enqueues (FIFO) before we return — the
|
||||
// same completion guarantee as the old per-plane blocking copies at half the exposed waits;
|
||||
// `sync: false` shifts the source-lifetime obligation to the caller (documented above).
|
||||
// Wrappers → live table.
|
||||
unsafe {
|
||||
copy_issue(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)", sync)?;
|
||||
copy_issue(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)", sync)
|
||||
copy_async(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
||||
copy_async(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")?;
|
||||
if sync {
|
||||
sync_copy_stream()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy our imported stacked-YUV444 [`DeviceBuffer`] into NVENC's three-plane CUDA surface
|
||||
@@ -1024,13 +1040,19 @@ pub fn copy_yuv444_to_device(
|
||||
Height: h,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: unsafe `copy_issue` device→device copy; the caller must have the shared
|
||||
// SAFETY: unsafe `copy_async` device→device enqueue; the caller must have the shared
|
||||
// context current (documented). `©` is a live local outliving the enqueue;
|
||||
// `src.ptr + pitch·h·i` stays within the live 3·H-row stacked allocation (`yuv444`
|
||||
// checked above), `dst_ptr`/`dst_pitch` is the caller's live NVENC plane; `w`×`h` fits
|
||||
// both; `sync: false` shifts the source-lifetime obligation to the caller (documented
|
||||
// above). Wrapper → live table.
|
||||
unsafe { copy_issue(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)", sync)? };
|
||||
// both. Completion is the trailing stream sync below (`sync`) or the caller's
|
||||
// stream-ordering obligation (`sync: false`, documented above). Wrapper → live table.
|
||||
unsafe { copy_async(©, "cuMemcpy2DAsync_v2(yuv444 plane dev->dev)")? };
|
||||
}
|
||||
if sync {
|
||||
// SAFETY: one stream sync after the last enqueue covers all three planes (FIFO) — the
|
||||
// same completion guarantee as the old per-plane blocking copies at a third of the
|
||||
// exposed waits. Context current per the caller's contract. Wrapper → live table.
|
||||
unsafe { sync_copy_stream()? };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1169,6 +1191,101 @@ impl Drop for ExternalDmabuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Vulkan **timeline** semaphore imported as a CUDA external semaphore — the cross-API ordering
|
||||
/// primitive for the stream-ordered cursor blend (`vkslot.rs`): CUDA [`signal`](Self::signal)s a
|
||||
/// value on this thread's copy stream once the input copy is enqueued, the Vulkan blend waits for
|
||||
/// and then advances the timeline on its queue, and CUDA [`wait`](Self::wait)s that advanced
|
||||
/// value before the encode — all ordering on-device, no CPU sync anywhere. One imported handle
|
||||
/// per [`VkSlotBlend`](super::vkslot::VkSlotBlend); values are monotonic for its lifetime.
|
||||
pub struct ExternalSemaphore {
|
||||
sem: CUexternalSemaphore,
|
||||
}
|
||||
|
||||
// SAFETY: `CUexternalSemaphore` is an opaque driver handle with no thread affinity (the driver
|
||||
// API allows use from any thread with the context current). It is uniquely owned here, used from
|
||||
// the encode thread but moved with its `VkSlotBlend`, and destroyed exactly once in `Drop` —
|
||||
// `Send` (not `Sync`) matches that single-thread-at-a-time use, like `ExternalDmabuf`.
|
||||
unsafe impl Send for ExternalSemaphore {}
|
||||
|
||||
impl ExternalSemaphore {
|
||||
/// Import a Vulkan timeline semaphore exported as an OPAQUE_FD (`vkGetSemaphoreFdKHR`). The
|
||||
/// fd is handed over: the driver owns it on success, we close it on failure. The shared
|
||||
/// context must be current.
|
||||
pub fn import_owned_timeline_fd(fd: i32) -> Result<ExternalSemaphore> {
|
||||
let mut desc = CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
|
||||
type_: CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD,
|
||||
..Default::default()
|
||||
};
|
||||
desc.handle[0] = fd as u32 as u64; // union member `int fd` (little-endian low bytes)
|
||||
let mut sem: CUexternalSemaphore = std::ptr::null_mut();
|
||||
// SAFETY: `cuImportExternalSemaphore` reads `&desc`, a live local `#[repr(C)]`
|
||||
// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (layout-asserted below) outliving the synchronous
|
||||
// call: `type_` is TIMELINE_SEMAPHORE_FD and `handle[0]` holds the fd in the union's
|
||||
// `int fd` low bytes. `&mut sem` is a live null-init out-param the driver writes the
|
||||
// imported handle into. Distinct locals → no aliasing. Wrapper → live table (caller holds
|
||||
// the context current).
|
||||
let r = unsafe { cuImportExternalSemaphore(&mut sem, &desc) };
|
||||
if r != 0 {
|
||||
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `fd`; we
|
||||
// still own it and close it exactly once here. `libc::close` acts on the integer alone.
|
||||
unsafe { libc::close(fd) };
|
||||
bail!("cuImportExternalSemaphore failed ({r}) — timeline-semaphore fd export/import unsupported?");
|
||||
}
|
||||
Ok(ExternalSemaphore { sem })
|
||||
}
|
||||
|
||||
/// Enqueue a signal that sets the timeline to `value` once all prior work on THIS THREAD's
|
||||
/// copy stream (the stream `copy_stream_handle` exposes) completes. No CPU wait.
|
||||
pub fn signal(&self, value: u64) -> Result<()> {
|
||||
let params = CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
|
||||
value,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `self.sem` is the live imported handle (this struct only exists after a
|
||||
// successful import; destroyed only in `Drop`). `&self.sem`/`¶ms` are live locals the
|
||||
// synchronous enqueue reads (count 1); the driver retains no pointer into Rust memory.
|
||||
// The stream is this thread's live copy stream. Wrapper → live table (context current).
|
||||
unsafe {
|
||||
ck(
|
||||
cuSignalExternalSemaphoresAsync(&self.sem, ¶ms, 1, copy_stream()),
|
||||
"cuSignalExternalSemaphoresAsync",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue a wait: work enqueued on THIS THREAD's copy stream after this call runs only once
|
||||
/// the timeline reaches `value`. No CPU wait.
|
||||
pub fn wait(&self, value: u64) -> Result<()> {
|
||||
let params = CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
|
||||
value,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: same contract as `signal` — live handle, live locals across the synchronous
|
||||
// enqueue, this thread's live copy stream. Wrapper → live table (context current).
|
||||
unsafe {
|
||||
ck(
|
||||
cuWaitExternalSemaphoresAsync(&self.sem, ¶ms, 1, copy_stream()),
|
||||
"cuWaitExternalSemaphoresAsync",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExternalSemaphore {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.sem` is the valid imported handle this struct exclusively owns, destroyed
|
||||
// exactly once here. The shared context is made current first because drop may run off
|
||||
// the import thread (`VkSlotBlend` teardown quiesces the GPU before dropping, so no
|
||||
// enqueued signal/wait still references the semaphore). Result ignored (best-effort).
|
||||
unsafe {
|
||||
if let Some(c) = CONTEXT.get() {
|
||||
let _ = cuCtxSetCurrent(c.0);
|
||||
}
|
||||
let _ = cuDestroyExternalSemaphore(self.sem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a pitched span starting at `src_ptr` (e.g. an [`ExternalDmabuf`] mapping at the chunk
|
||||
/// offset) into `dst`. The shared context must be current on this thread.
|
||||
pub fn copy_pitched_to_buffer(
|
||||
@@ -1243,3 +1360,31 @@ pub fn copy_pitched_nv12_to_buffer(
|
||||
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::mem::{offset_of, size_of};
|
||||
|
||||
/// The external-semaphore param structs are hand-flattened from cuda.h unions — assert the
|
||||
/// layout against the C definitions so a transcription slip fails in CI, not in the driver.
|
||||
#[test]
|
||||
fn external_semaphore_struct_layouts_match_cuda_h() {
|
||||
// CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC: type(4)+pad(4)+union(16)+flags(4)+reserved(64) = 96.
|
||||
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC>(), 96);
|
||||
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, handle), 8);
|
||||
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC, flags), 24);
|
||||
|
||||
// CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(8)+
|
||||
// reserved[12](48)} = 72, flags at 72, reserved[16] → 144 total.
|
||||
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS>(), 144);
|
||||
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, value), 0);
|
||||
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS, flags), 72);
|
||||
|
||||
// CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS: params{fence(8)+nvSciSync(8)+keyedMutex(16,
|
||||
// tail-padded)+reserved[10](40)} = 72, flags at 72, reserved[16] → 144 total.
|
||||
assert_eq!(size_of::<CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS>(), 144);
|
||||
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, value), 0);
|
||||
assert_eq!(offset_of!(CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS, flags), 72);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub type CUdeviceptr = u64;
|
||||
pub type CUgraphicsResource = *mut c_void;
|
||||
pub type CUarray = *mut c_void;
|
||||
pub type CUexternalMemory = *mut c_void; // opaque CUextMemory_st*
|
||||
pub type CUexternalSemaphore = *mut c_void; // opaque CUextSemaphore_st*
|
||||
|
||||
/// `CUmemorytype` (cuda.h): HOST=1, DEVICE=2, ARRAY=3, UNIFIED=4.
|
||||
pub const CU_MEMORYTYPE_DEVICE: c_uint = 2;
|
||||
@@ -84,6 +85,60 @@ pub struct CUDA_EXTERNAL_MEMORY_BUFFER_DESC {
|
||||
|
||||
pub const CU_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
|
||||
|
||||
/// `CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC` (cuda.h, 64-bit layout). Same union-flattening as the
|
||||
/// memory desc above: `handle` is a union whose largest member is the win32 two-pointer struct
|
||||
/// (16 bytes, align 8); for the fd-carrying types only the first 4 bytes (the `int fd`) are read.
|
||||
/// No `size` field — a semaphore has none.
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
pub struct CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC {
|
||||
pub type_: c_uint, // CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD = 9
|
||||
pub(crate) _pad: u32,
|
||||
pub handle: [u64; 2], // union { int fd; {void*,void*} win32; const void* nvSciSyncObj }
|
||||
pub flags: c_uint,
|
||||
pub(crate) reserved: [c_uint; 16],
|
||||
pub(crate) _pad2: u32,
|
||||
}
|
||||
|
||||
/// `CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS` (cuda.h, 64-bit layout), flattened: `params` nests
|
||||
/// `fence.value` (the only member we set — the timeline value to signal), the `nvSciSync` union,
|
||||
/// `keyedMutex.key`, then 12 reserved words; `flags` + 16 reserved words follow. 144 bytes total
|
||||
/// (layout-asserted in `super`'s tests).
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
pub struct CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS {
|
||||
pub value: u64, // params.fence.value — the timeline value this signal sets
|
||||
pub(crate) _nv_sci_sync: u64,
|
||||
pub(crate) _keyed_mutex_key: u64,
|
||||
pub(crate) _params_reserved: [c_uint; 12],
|
||||
pub flags: c_uint,
|
||||
pub(crate) reserved: [c_uint; 16],
|
||||
pub(crate) _pad: u32,
|
||||
}
|
||||
|
||||
/// `CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS` (cuda.h, 64-bit layout), flattened like the signal
|
||||
/// params. The C `keyedMutex` member is `{ u64 key; u32 timeoutMs; }` — size 16 with tail
|
||||
/// padding, hence the explicit pad word before the 10 reserved words. 144 bytes total.
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
pub struct CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS {
|
||||
pub value: u64, // params.fence.value — wait until the timeline reaches this value
|
||||
pub(crate) _nv_sci_sync: u64,
|
||||
pub(crate) _keyed_mutex_key: u64,
|
||||
pub(crate) _keyed_mutex_timeout: c_uint,
|
||||
pub(crate) _keyed_mutex_pad: u32,
|
||||
pub(crate) _params_reserved: [c_uint; 10],
|
||||
pub flags: c_uint,
|
||||
pub(crate) reserved: [c_uint; 16],
|
||||
pub(crate) _pad: u32,
|
||||
}
|
||||
|
||||
/// `CUexternalSemaphoreHandleType` (cuda.h): a Vulkan **timeline** semaphore exported as an
|
||||
/// OPAQUE_FD (`vkGetSemaphoreFdKHR`). Needs driver ≥ 460 (CUDA 11.2) — far below the NVENC 12.1
|
||||
/// floor this backend already requires, so import failure means "driver refused", not "too old
|
||||
/// to try".
|
||||
pub const CU_EXTERNAL_SEMAPHORE_HANDLE_TYPE_TIMELINE_SEMAPHORE_FD: c_uint = 9;
|
||||
|
||||
/// `CUipcMemHandle` (cuda.h): an opaque 64-byte struct identifying a device allocation across
|
||||
/// processes. Produced by `cuIpcGetMemHandle` in the exporting process, consumed by
|
||||
/// `cuIpcOpenMemHandle` in the importer — passed **by value**, matching the C
|
||||
@@ -139,6 +194,23 @@ pub(crate) struct CudaApi {
|
||||
*const CUDA_EXTERNAL_MEMORY_BUFFER_DESC,
|
||||
) -> CUresult,
|
||||
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
||||
cuImportExternalSemaphore: unsafe extern "C" fn(
|
||||
*mut CUexternalSemaphore,
|
||||
*const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
|
||||
) -> CUresult,
|
||||
cuDestroyExternalSemaphore: unsafe extern "C" fn(CUexternalSemaphore) -> CUresult,
|
||||
cuSignalExternalSemaphoresAsync: unsafe extern "C" fn(
|
||||
*const CUexternalSemaphore,
|
||||
*const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
|
||||
c_uint,
|
||||
CUstream,
|
||||
) -> CUresult,
|
||||
cuWaitExternalSemaphoresAsync: unsafe extern "C" fn(
|
||||
*const CUexternalSemaphore,
|
||||
*const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
|
||||
c_uint,
|
||||
CUstream,
|
||||
) -> CUresult,
|
||||
cuIpcGetMemHandle: unsafe extern "C" fn(*mut CUipcMemHandle, CUdeviceptr) -> CUresult,
|
||||
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
|
||||
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
|
||||
@@ -206,6 +278,14 @@ pub(crate) fn cuda_api() -> Option<&'static CudaApi> {
|
||||
.get(b"cuExternalMemoryGetMappedBuffer\0")
|
||||
.ok()?,
|
||||
cuDestroyExternalMemory: *lib.get(b"cuDestroyExternalMemory\0").ok()?,
|
||||
// External-semaphore interop (the stream-ordered cursor blend): all four are
|
||||
// CUDA 10.0 entry points, far older than anything else this table requires.
|
||||
cuImportExternalSemaphore: *lib.get(b"cuImportExternalSemaphore\0").ok()?,
|
||||
cuDestroyExternalSemaphore: *lib.get(b"cuDestroyExternalSemaphore\0").ok()?,
|
||||
cuSignalExternalSemaphoresAsync: *lib
|
||||
.get(b"cuSignalExternalSemaphoresAsync\0")
|
||||
.ok()?,
|
||||
cuWaitExternalSemaphoresAsync: *lib.get(b"cuWaitExternalSemaphoresAsync\0").ok()?,
|
||||
cuIpcGetMemHandle: *lib.get(b"cuIpcGetMemHandle\0").ok()?,
|
||||
// CUDA 11 renamed the entry point (per-thread-stream ABI split); every modern
|
||||
// driver exports `_v2`, but accept the unsuffixed one too (same signature).
|
||||
@@ -381,6 +461,43 @@ pub(crate) unsafe fn cuDestroyExternalMemory(ext_mem: CUexternalMemory) -> CUres
|
||||
None => CU_ERROR_NOT_LOADED,
|
||||
}
|
||||
}
|
||||
pub(crate) unsafe fn cuImportExternalSemaphore(
|
||||
ext_sem_out: *mut CUexternalSemaphore,
|
||||
sem_handle_desc: *const CUDA_EXTERNAL_SEMAPHORE_HANDLE_DESC,
|
||||
) -> CUresult {
|
||||
match cuda_api() {
|
||||
Some(a) => (a.cuImportExternalSemaphore)(ext_sem_out, sem_handle_desc),
|
||||
None => CU_ERROR_NOT_LOADED,
|
||||
}
|
||||
}
|
||||
pub(crate) unsafe fn cuDestroyExternalSemaphore(ext_sem: CUexternalSemaphore) -> CUresult {
|
||||
match cuda_api() {
|
||||
Some(a) => (a.cuDestroyExternalSemaphore)(ext_sem),
|
||||
None => CU_ERROR_NOT_LOADED,
|
||||
}
|
||||
}
|
||||
pub(crate) unsafe fn cuSignalExternalSemaphoresAsync(
|
||||
ext_sems: *const CUexternalSemaphore,
|
||||
params: *const CUDA_EXTERNAL_SEMAPHORE_SIGNAL_PARAMS,
|
||||
count: c_uint,
|
||||
stream: CUstream,
|
||||
) -> CUresult {
|
||||
match cuda_api() {
|
||||
Some(a) => (a.cuSignalExternalSemaphoresAsync)(ext_sems, params, count, stream),
|
||||
None => CU_ERROR_NOT_LOADED,
|
||||
}
|
||||
}
|
||||
pub(crate) unsafe fn cuWaitExternalSemaphoresAsync(
|
||||
ext_sems: *const CUexternalSemaphore,
|
||||
params: *const CUDA_EXTERNAL_SEMAPHORE_WAIT_PARAMS,
|
||||
count: c_uint,
|
||||
stream: CUstream,
|
||||
) -> CUresult {
|
||||
match cuda_api() {
|
||||
Some(a) => (a.cuWaitExternalSemaphoresAsync)(ext_sems, params, count, stream),
|
||||
None => CU_ERROR_NOT_LOADED,
|
||||
}
|
||||
}
|
||||
pub(crate) unsafe fn cuIpcGetMemHandle(handle: *mut CUipcMemHandle, dptr: CUdeviceptr) -> CUresult {
|
||||
match cuda_api() {
|
||||
Some(a) => (a.cuIpcGetMemHandle)(handle, dptr),
|
||||
|
||||
@@ -12,11 +12,24 @@
|
||||
//!
|
||||
//! The direct-SDK NVENC encoder allocates its input ring through [`VkSlotBlend::alloc_slot`]
|
||||
//! instead of `cuMemAllocPitch`: same contiguous layouts (`InputSurface` docs), but the memory is
|
||||
//! Vulkan external memory both APIs address. Per cursor-bearing frame the encoder CPU-syncs its
|
||||
//! CUDA copy, then [`VkSlotBlend::blend`] dispatches the compute blend over the cursor's
|
||||
//! rectangle and fence-waits — the same coherence ceremony [`super::vulkan::VkBridge`] ships for
|
||||
//! its CSC (fence-ordered cross-API access on NVIDIA, no queue-family transfer needed). Frames
|
||||
//! without a cursor never touch Vulkan, keeping the stream-ordered fast path intact.
|
||||
//! Vulkan external memory both APIs address.
|
||||
//!
|
||||
//! Cursor-bearing frames blend one of two ways:
|
||||
//!
|
||||
//! * **Stream-ordered** ([`blend_ref_ordered`](VkSlotBlend::blend_ref_ordered), the fast path
|
||||
//! when the driver exports a timeline semaphore to CUDA): the encoder enqueues its CUDA copy
|
||||
//! with no CPU sync, CUDA signals the shared timeline on the copy stream, the blend submission
|
||||
//! waits for and then advances it on the Vulkan queue, and CUDA waits the advanced value
|
||||
//! before the encode — the whole copy→blend→encode chain orders on-device, so a visible
|
||||
//! cursor costs the submit path nothing. (Before this, EVERY cursor frame took the CPU-synced
|
||||
//! path below; under gamescope — which composites the pointer into every frame — that
|
||||
//! serialized submit behind the game's GPU load and capped a 120 fps session near 80.)
|
||||
//! * **CPU-synced** ([`blend_ref`](VkSlotBlend::blend_ref), the fallback when timeline bring-up
|
||||
//! failed): the encoder blocks on its CUDA copy, the blend fence-waits — the same coherence
|
||||
//! ceremony [`super::vulkan::VkBridge`] ships for its CSC (fence-ordered cross-API access on
|
||||
//! NVIDIA, no queue-family transfer needed).
|
||||
//!
|
||||
//! Frames without a cursor never touch Vulkan at all.
|
||||
//!
|
||||
//! Falls back cleanly: if bring-up fails the encoder allocates plain CUDA surfaces and composite
|
||||
//! mode degrades to no cursor (warned once) — never a failed session.
|
||||
@@ -87,12 +100,36 @@ pub struct VkSlotRef {
|
||||
}
|
||||
|
||||
/// One allocated slot's backing objects, freed together in reverse order (CUDA mapping first).
|
||||
/// Each slot carries its own command buffer + descriptor set so ordered blends can be in flight
|
||||
/// on several slots at once (the shared-single-set design raced the next recording against a
|
||||
/// still-executing submission); the set's bindings are written once here — the slot buffer never
|
||||
/// changes and the cursor staging buffer is shared.
|
||||
struct SlotAlloc {
|
||||
buffer: vk::Buffer,
|
||||
memory: vk::DeviceMemory,
|
||||
/// CUDA's import of the exported OPAQUE_FD — must drop BEFORE the Vulkan memory is freed.
|
||||
cuda: cuda::ExternalDmabuf,
|
||||
size: u64,
|
||||
cmd: vk::CommandBuffer,
|
||||
desc: vk::DescriptorSet,
|
||||
}
|
||||
|
||||
/// The cross-API ordering state for stream-ordered blends: one Vulkan timeline semaphore,
|
||||
/// exported as an OPAQUE_FD and imported into CUDA. `None` when the driver lacks timeline
|
||||
/// semaphores or the export/import failed — blends then stay CPU-synced ([`VkSlotBlend::blend_ref`]).
|
||||
struct Timeline {
|
||||
sem: vk::Semaphore,
|
||||
/// `vkWaitSemaphoresKHR` — the CPU-side quiesce for cursor-bitmap uploads and teardown
|
||||
/// (the 1.1 device gets the entry point from `VK_KHR_timeline_semaphore`).
|
||||
ts: ash::khr::timeline_semaphore::Device,
|
||||
/// CUDA's import; its `signal`/`wait` enqueue on the encode thread's copy stream.
|
||||
cuda: cuda::ExternalSemaphore,
|
||||
/// Last timeline value handed out (monotonic for the device's lifetime, never reused —
|
||||
/// each ordered blend consumes two: copy-done, then blend-done).
|
||||
ticket: u64,
|
||||
/// Last blend-done value an ACCEPTED `vkQueueSubmit` will signal — what upload/teardown
|
||||
/// quiesce on. Only advanced on submit success: a value from a failed submit would never
|
||||
/// be signaled and a quiesce on it would time out.
|
||||
last_blend: u64,
|
||||
}
|
||||
|
||||
/// 28-byte push-constant block matching `cursor_blend.comp`'s `Push`.
|
||||
@@ -114,14 +151,12 @@ pub struct VkSlotBlend {
|
||||
ext_fd: ash::khr::external_memory_fd::Device,
|
||||
queue: vk::Queue,
|
||||
cmd_pool: vk::CommandPool,
|
||||
cmd: vk::CommandBuffer,
|
||||
fence: vk::Fence,
|
||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||
shader: vk::ShaderModule,
|
||||
desc_layout: vk::DescriptorSetLayout,
|
||||
pipe_layout: vk::PipelineLayout,
|
||||
desc_pool: vk::DescriptorPool,
|
||||
desc_set: vk::DescriptorSet,
|
||||
/// One pipeline per [`SlotFormat`], indexed by `mode()` (spec constant).
|
||||
pipelines: [vk::Pipeline; 3],
|
||||
/// Host-visible cursor bitmap staging (CURSOR_MAX²·4, tight rows), persistently mapped.
|
||||
@@ -129,6 +164,8 @@ pub struct VkSlotBlend {
|
||||
cur_mem: vk::DeviceMemory,
|
||||
cur_map: *mut u8,
|
||||
slots: Vec<SlotAlloc>,
|
||||
/// Stream-ordered blend support (`None` = CPU-synced blends only). See [`Timeline`].
|
||||
timeline: Option<Timeline>,
|
||||
}
|
||||
|
||||
// SAFETY: raw Vulkan handles + a persistently-mapped pointer, all uniquely owned by this struct
|
||||
@@ -182,14 +219,44 @@ impl VkSlotBlend {
|
||||
let qci = [vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(qf)
|
||||
.queue_priorities(&prio)];
|
||||
let exts = [ash::khr::external_memory_fd::NAME.as_ptr()];
|
||||
let device = match instance.create_device(
|
||||
phys,
|
||||
&vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&qci)
|
||||
.enabled_extension_names(&exts),
|
||||
None,
|
||||
) {
|
||||
// Timeline-semaphore export to CUDA (the stream-ordered blend) is optional: probe the
|
||||
// device extensions + feature bit and enable them only where present, so a driver
|
||||
// without them still gets a working (CPU-synced) blend device. NVIDIA has shipped
|
||||
// both extensions since ~2019; the probe is for exotic/legacy stacks.
|
||||
let want_timeline = {
|
||||
let have_exts = instance
|
||||
.enumerate_device_extension_properties(phys)
|
||||
.map(|props| {
|
||||
let has = |name: &std::ffi::CStr| {
|
||||
props
|
||||
.iter()
|
||||
.any(|p| p.extension_name_as_c_str().is_ok_and(|n| n == name))
|
||||
};
|
||||
has(ash::khr::timeline_semaphore::NAME)
|
||||
&& has(ash::khr::external_semaphore_fd::NAME)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
have_exts && {
|
||||
let mut tl = vk::PhysicalDeviceTimelineSemaphoreFeatures::default();
|
||||
let mut f2 = vk::PhysicalDeviceFeatures2::default().push_next(&mut tl);
|
||||
instance.get_physical_device_features2(phys, &mut f2);
|
||||
tl.timeline_semaphore == vk::TRUE
|
||||
}
|
||||
};
|
||||
let mut exts = vec![ash::khr::external_memory_fd::NAME.as_ptr()];
|
||||
if want_timeline {
|
||||
exts.push(ash::khr::timeline_semaphore::NAME.as_ptr());
|
||||
exts.push(ash::khr::external_semaphore_fd::NAME.as_ptr());
|
||||
}
|
||||
let mut tl_enable =
|
||||
vk::PhysicalDeviceTimelineSemaphoreFeatures::default().timeline_semaphore(true);
|
||||
let mut dci = vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&qci)
|
||||
.enabled_extension_names(&exts);
|
||||
if want_timeline {
|
||||
dci = dci.push_next(&mut tl_enable);
|
||||
}
|
||||
let device = match instance.create_device(phys, &dci, None) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
instance.destroy_instance(None);
|
||||
@@ -207,30 +274,93 @@ impl VkSlotBlend {
|
||||
ext_fd,
|
||||
queue,
|
||||
cmd_pool: vk::CommandPool::null(),
|
||||
cmd: vk::CommandBuffer::null(),
|
||||
fence: vk::Fence::null(),
|
||||
mem_props,
|
||||
shader: vk::ShaderModule::null(),
|
||||
desc_layout: vk::DescriptorSetLayout::null(),
|
||||
pipe_layout: vk::PipelineLayout::null(),
|
||||
desc_pool: vk::DescriptorPool::null(),
|
||||
desc_set: vk::DescriptorSet::null(),
|
||||
pipelines: [vk::Pipeline::null(); 3],
|
||||
cur_buf: vk::Buffer::null(),
|
||||
cur_mem: vk::DeviceMemory::null(),
|
||||
cur_map: std::ptr::null_mut(),
|
||||
slots: Vec::new(),
|
||||
timeline: None,
|
||||
};
|
||||
me.init_objects(qf).inspect_err(|_| {
|
||||
// `Drop` runs the same teardown and tolerates the nulls left by a partial init.
|
||||
})?;
|
||||
if want_timeline {
|
||||
// Non-fatal: any failure (export refused, CUDA import refused) leaves
|
||||
// `timeline` None and blends CPU-synced — never a failed bring-up.
|
||||
if let Err(e) = me.init_timeline() {
|
||||
tracing::info!(
|
||||
error = %format!("{e:#}"),
|
||||
"cursor blend timeline export unavailable — blends stay CPU-synced"
|
||||
);
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
stream_ordered = me.timeline.is_some(),
|
||||
"Vulkan slot blend ready (exportable NVENC inputs + SPIR-V cursor blend)"
|
||||
);
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring up the [`Timeline`]: create an exportable timeline semaphore, export its OPAQUE_FD,
|
||||
/// import it into CUDA. Only called when the device was created with the timeline extensions.
|
||||
fn init_timeline(&mut self) -> Result<()> {
|
||||
// SAFETY: ash calls on the live `self.device` with builder infos from locals outliving
|
||||
// each synchronous call. The semaphore is destroyed on every failure path after its
|
||||
// creation; the exported fd is owned by `import_owned_timeline_fd` (the driver takes it
|
||||
// on success, it closes it on failure). The shared CUDA context is current: the encoder
|
||||
// brings this device up from its encode thread with the context established.
|
||||
unsafe {
|
||||
let mut type_ci = vk::SemaphoreTypeCreateInfo::default()
|
||||
.semaphore_type(vk::SemaphoreType::TIMELINE)
|
||||
.initial_value(0);
|
||||
let mut export = vk::ExportSemaphoreCreateInfo::default()
|
||||
.handle_types(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD);
|
||||
let sem = self
|
||||
.device
|
||||
.create_semaphore(
|
||||
&vk::SemaphoreCreateInfo::default()
|
||||
.push_next(&mut type_ci)
|
||||
.push_next(&mut export),
|
||||
None,
|
||||
)
|
||||
.context("create timeline semaphore")?;
|
||||
let sem_fd = ash::khr::external_semaphore_fd::Device::new(&self.instance, &self.device);
|
||||
let fd = match sem_fd.get_semaphore_fd(
|
||||
&vk::SemaphoreGetFdInfoKHR::default()
|
||||
.semaphore(sem)
|
||||
.handle_type(vk::ExternalSemaphoreHandleTypeFlags::OPAQUE_FD),
|
||||
) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
self.device.destroy_semaphore(sem, None);
|
||||
return Err(e).context("vkGetSemaphoreFdKHR(timeline)");
|
||||
}
|
||||
};
|
||||
let cuda_sem = match cuda::ExternalSemaphore::import_owned_timeline_fd(fd) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.device.destroy_semaphore(sem, None);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
self.timeline = Some(Timeline {
|
||||
sem,
|
||||
ts: ash::khr::timeline_semaphore::Device::new(&self.instance, &self.device),
|
||||
cuda: cuda_sem,
|
||||
ticket: 0,
|
||||
last_blend: 0,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-device objects: command machinery, cursor staging, descriptor + pipelines.
|
||||
fn init_objects(&mut self, qf: u32) -> Result<()> {
|
||||
// SAFETY: same contract as `new` — ash calls on the live `self.device` with builder infos
|
||||
@@ -246,14 +376,6 @@ impl VkSlotBlend {
|
||||
None,
|
||||
)
|
||||
.context("create command pool")?;
|
||||
self.cmd = d
|
||||
.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(self.cmd_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(1),
|
||||
)
|
||||
.context("allocate command buffer")?[0];
|
||||
self.fence = d
|
||||
.create_fence(&vk::FenceCreateInfo::default(), None)
|
||||
.context("create fence")?;
|
||||
@@ -320,25 +442,21 @@ impl VkSlotBlend {
|
||||
None,
|
||||
)
|
||||
.context("create pipeline layout")?;
|
||||
// Per-slot sets (one per ring slot, written once at `alloc_slot`; freed by
|
||||
// `free_slots` on ring rebuilds, hence FREE_DESCRIPTOR_SET). 64 is comfortably
|
||||
// above any ring depth (the encoder's POOL is 8).
|
||||
let pool_sizes = [vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(2)];
|
||||
.descriptor_count(128)];
|
||||
self.desc_pool = d
|
||||
.create_descriptor_pool(
|
||||
&vk::DescriptorPoolCreateInfo::default()
|
||||
.max_sets(1)
|
||||
.flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
|
||||
.max_sets(64)
|
||||
.pool_sizes(&pool_sizes),
|
||||
None,
|
||||
)
|
||||
.context("create descriptor pool")?;
|
||||
let dls = [self.desc_layout];
|
||||
self.desc_set = d
|
||||
.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(self.desc_pool)
|
||||
.set_layouts(&dls),
|
||||
)
|
||||
.context("allocate descriptor set")?[0];
|
||||
|
||||
// The shader + one pipeline per MODE (spec constant 0).
|
||||
if CURSOR_SPV.len() % 4 != 0 {
|
||||
@@ -465,6 +583,59 @@ impl VkSlotBlend {
|
||||
return Err(e).context("cuImportExternalMemory(slot OPAQUE_FD)");
|
||||
}
|
||||
};
|
||||
// The slot's own descriptor set + command buffer (see `SlotAlloc`). Bindings are
|
||||
// written once here: binding 0 is this slot's buffer (immutable for its lifetime),
|
||||
// binding 1 the shared cursor staging buffer.
|
||||
let dls = [self.desc_layout];
|
||||
let desc = match d.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(self.desc_pool)
|
||||
.set_layouts(&dls),
|
||||
) {
|
||||
Ok(s) => s[0],
|
||||
Err(e) => {
|
||||
drop(ext); // CUDA's view of the memory goes first
|
||||
d.free_memory(memory, None);
|
||||
d.destroy_buffer(buffer, None);
|
||||
return Err(e).context("allocate slot descriptor set");
|
||||
}
|
||||
};
|
||||
let surf_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(buffer)
|
||||
.offset(0)
|
||||
.range(size)];
|
||||
let cur_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(self.cur_buf)
|
||||
.offset(0)
|
||||
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
|
||||
let writes = [
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(desc)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&surf_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(desc)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&cur_info),
|
||||
];
|
||||
d.update_descriptor_sets(&writes, &[]);
|
||||
let cmd = match d.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(self.cmd_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(1),
|
||||
) {
|
||||
Ok(c) => c[0],
|
||||
Err(e) => {
|
||||
let _ = d.free_descriptor_sets(self.desc_pool, &[desc]);
|
||||
drop(ext); // CUDA's view of the memory goes first
|
||||
d.free_memory(memory, None);
|
||||
d.destroy_buffer(buffer, None);
|
||||
return Err(e).context("allocate slot command buffer");
|
||||
}
|
||||
};
|
||||
let r = VkSlotRef {
|
||||
ptr: ext.ptr,
|
||||
pitch: pitch as usize,
|
||||
@@ -475,7 +646,8 @@ impl VkSlotBlend {
|
||||
buffer,
|
||||
memory,
|
||||
cuda: ext,
|
||||
size,
|
||||
cmd,
|
||||
desc,
|
||||
});
|
||||
Ok(r)
|
||||
}
|
||||
@@ -485,12 +657,26 @@ impl VkSlotBlend {
|
||||
/// first (field order in [`SlotAlloc`] frees `cuda` via its own `Drop` before we free the VK
|
||||
/// objects explicitly here).
|
||||
pub fn free_slots(&mut self) {
|
||||
// Ordered blends return with their work still on the queue — quiesce before freeing the
|
||||
// buffers/sets it references. `device_wait_idle` (not a timeline wait) so a submission
|
||||
// whose CUDA copy-done signal never fired is still covered; this tiny device idles in
|
||||
// microseconds outside that pathological case. CPU-synced blends need nothing (they
|
||||
// fence-wait before returning), so a `None` timeline skips it.
|
||||
if self.timeline.is_some() && !self.slots.is_empty() {
|
||||
// SAFETY: single-threaded owner; no other thread touches this device or its queue.
|
||||
unsafe {
|
||||
let _ = self.device.device_wait_idle();
|
||||
}
|
||||
}
|
||||
for s in self.slots.drain(..) {
|
||||
drop(s.cuda); // CUDA's view of the memory goes first
|
||||
// SAFETY: `buffer`/`memory` were created in `alloc_slot`, are uniquely owned by the
|
||||
// drained `SlotAlloc`, and are destroyed exactly once here. No queue work can be
|
||||
// in flight: every `blend` fence-waits before returning.
|
||||
// SAFETY: `buffer`/`memory`/`cmd`/`desc` were created in `alloc_slot`, are uniquely
|
||||
// owned by the drained `SlotAlloc`, and are destroyed exactly once here. No queue
|
||||
// work is in flight: CPU-synced blends fence-wait before returning, and ordered
|
||||
// blends were quiesced by the `device_wait_idle` above.
|
||||
unsafe {
|
||||
let _ = self.device.free_descriptor_sets(self.desc_pool, &[s.desc]);
|
||||
self.device.free_command_buffers(self.cmd_pool, &[s.cmd]);
|
||||
self.device.destroy_buffer(s.buffer, None);
|
||||
self.device.free_memory(s.memory, None);
|
||||
}
|
||||
@@ -498,21 +684,188 @@ impl VkSlotBlend {
|
||||
}
|
||||
|
||||
/// Upload the cursor RGBA (`cw*ch*4`, tight rows) into the mapped staging buffer. Call only
|
||||
/// when the bitmap changes; position moves are push constants.
|
||||
/// when the bitmap changes; position moves are push constants. Quiesces any in-flight
|
||||
/// ordered blend first (bitmap changes are rare — pointer-shape flips — so the occasional
|
||||
/// bounded CPU wait here is the trade that keeps the per-frame path sync-free).
|
||||
pub fn upload_cursor(&mut self, rgba: &[u8], cw: u32, ch: u32) {
|
||||
if let Some(t) = &self.timeline {
|
||||
if t.last_blend > 0 {
|
||||
let sems = [t.sem];
|
||||
let values = [t.last_blend];
|
||||
// SAFETY: `t.sem` is the live timeline semaphore; the wait info's arrays are
|
||||
// locals outliving the synchronous call. `last_blend` only holds values an
|
||||
// accepted submit will signal, so the wait terminates (the timeout is the
|
||||
// backstop for a wedged queue — then we proceed and risk one torn cursor
|
||||
// bitmap, never UB: the staging buffer stays alive regardless).
|
||||
let r = unsafe {
|
||||
t.ts.wait_semaphores(
|
||||
&vk::SemaphoreWaitInfo::default()
|
||||
.semaphores(&sems)
|
||||
.values(&values),
|
||||
1_000_000_000,
|
||||
)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
tracing::warn!(
|
||||
error = ?e,
|
||||
"cursor upload quiesce failed — proceeding (a torn cursor bitmap for \
|
||||
one frame at worst)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let cw = cw.min(CURSOR_MAX);
|
||||
let ch = ch.min(CURSOR_MAX);
|
||||
let len = (cw * ch * 4) as usize;
|
||||
let len = len.min(rgba.len());
|
||||
// SAFETY: `cur_map` is the live persistent mapping of the CURSOR_MAX²·4 host-coherent
|
||||
// allocation (created in `init_objects`, unmapped only in `Drop`); `len` is clamped to
|
||||
// both the source slice and the buffer capacity. No blend is in flight (every `blend`
|
||||
// fence-waits before returning), so no GPU read races this host write.
|
||||
// both the source slice and the buffer capacity. No blend reads race this host write:
|
||||
// CPU-synced blends fence-wait before returning, and ordered blends were quiesced via
|
||||
// the timeline wait above.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(rgba.as_ptr(), self.cur_map, len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream-ordered blends available: the timeline semaphore was exported to CUDA at bring-up,
|
||||
/// so [`blend_ref_ordered`](Self::blend_ref_ordered) can order copy→blend→encode on-device.
|
||||
pub fn ordered_ready(&self) -> bool {
|
||||
self.timeline.is_some()
|
||||
}
|
||||
|
||||
/// Last timeline value handed out (0 = no ordered blend submitted yet) — test observability
|
||||
/// for "did the ordered path actually run".
|
||||
pub fn ordered_ticket(&self) -> u64 {
|
||||
self.timeline.as_ref().map_or(0, |t| t.ticket)
|
||||
}
|
||||
|
||||
/// Push constants + dispatch geometry for one blend (must match cursor_blend.comp): ARGB =
|
||||
/// per cursor px; NV12/YUV444 = per word-aligned 4-px span × (2-row blocks | rows). `None`
|
||||
/// when the clamped cursor rect is empty (nothing to dispatch).
|
||||
fn blend_geometry(
|
||||
slot: &VkSlotRef,
|
||||
fmt: SlotFormat,
|
||||
surf_w: u32,
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
) -> Option<(Push, u32, u32)> {
|
||||
let cw = cw.min(CURSOR_MAX);
|
||||
let ch = ch.min(CURSOR_MAX);
|
||||
if cw == 0 || ch == 0 {
|
||||
return None;
|
||||
}
|
||||
let push = Push {
|
||||
pitch: slot.pitch as u32,
|
||||
surf_w,
|
||||
surf_h: slot.height,
|
||||
cur_w: cw,
|
||||
cur_h: ch,
|
||||
ox,
|
||||
oy,
|
||||
};
|
||||
let (gx, gy) = match fmt {
|
||||
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
|
||||
_ => {
|
||||
let x0 = (ox >> 2) << 2;
|
||||
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
|
||||
let rows = match fmt {
|
||||
SlotFormat::Nv12 => ch.div_ceil(2),
|
||||
_ => ch,
|
||||
};
|
||||
(spans.div_ceil(8), rows.div_ceil(8))
|
||||
}
|
||||
};
|
||||
Some((push, gx, gy))
|
||||
}
|
||||
|
||||
/// Record the blend (barriers + bind + push constants + dispatch) into `id`'s own command
|
||||
/// buffer and return it, ready for submission.
|
||||
///
|
||||
/// # Safety
|
||||
/// The slot's previous submission must have completed: sync blends fence-wait before
|
||||
/// returning; ordered blends rely on the encoder's ring-reuse discipline (a slot is reused
|
||||
/// only after its encode — GPU-ordered after its blend — was polled).
|
||||
unsafe fn record_blend(
|
||||
&self,
|
||||
id: usize,
|
||||
fmt: SlotFormat,
|
||||
push: &Push,
|
||||
gx: u32,
|
||||
gy: u32,
|
||||
) -> Result<vk::CommandBuffer> {
|
||||
let alloc = self
|
||||
.slots
|
||||
.get(id)
|
||||
.ok_or_else(|| anyhow!("bad slot id {id}"))?;
|
||||
let d = &self.device;
|
||||
let cmd = alloc.cmd;
|
||||
d.begin_command_buffer(
|
||||
cmd,
|
||||
&vk::CommandBufferBeginInfo::default()
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)
|
||||
.context("begin blend cmd")?;
|
||||
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
|
||||
// the shader (external-memory coherence ceremony; NVIDIA honors this with the
|
||||
// fence/semaphore ordering alone, the barrier is the spec-shaped belt-and-braces).
|
||||
let acquire = [vk::MemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
|
||||
d.cmd_pipeline_barrier(
|
||||
cmd,
|
||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&acquire,
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
d.cmd_bind_pipeline(
|
||||
cmd,
|
||||
vk::PipelineBindPoint::COMPUTE,
|
||||
self.pipelines[fmt.mode() as usize],
|
||||
);
|
||||
d.cmd_bind_descriptor_sets(
|
||||
cmd,
|
||||
vk::PipelineBindPoint::COMPUTE,
|
||||
self.pipe_layout,
|
||||
0,
|
||||
&[alloc.desc],
|
||||
&[],
|
||||
);
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
(push as *const Push) as *const u8,
|
||||
std::mem::size_of::<Push>(),
|
||||
);
|
||||
d.cmd_push_constants(
|
||||
cmd,
|
||||
self.pipe_layout,
|
||||
vk::ShaderStageFlags::COMPUTE,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
d.cmd_dispatch(cmd, gx.max(1), gy.max(1), 1);
|
||||
// Release the shader's writes so the downstream CUDA/NVENC reads (fence- or
|
||||
// semaphore-ordered) see them.
|
||||
let release = [vk::MemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
|
||||
d.cmd_pipeline_barrier(
|
||||
cmd,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||
vk::DependencyFlags::empty(),
|
||||
&release,
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
d.end_command_buffer(cmd).context("end blend cmd")?;
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Blend the uploaded cursor into `slot` at `(ox, oy)`: record, submit, fence-wait. The
|
||||
/// caller has CPU-synced its CUDA frame copy first; the fence wait makes the shader's writes
|
||||
/// visible to the subsequent NVENC encode (the `VkBridge` precedent: fence-ordered cross-API
|
||||
@@ -528,129 +881,20 @@ impl VkSlotBlend {
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
) -> Result<()> {
|
||||
let alloc = self
|
||||
.slots
|
||||
.get(slot.id)
|
||||
.ok_or_else(|| anyhow!("bad slot id {}", slot.id))?;
|
||||
let cw = cw.min(CURSOR_MAX);
|
||||
let ch = ch.min(CURSOR_MAX);
|
||||
if cw == 0 || ch == 0 {
|
||||
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
|
||||
return Ok(());
|
||||
}
|
||||
let push = Push {
|
||||
pitch: slot.pitch as u32,
|
||||
surf_w,
|
||||
surf_h: slot.height,
|
||||
cur_w: cw,
|
||||
cur_h: ch,
|
||||
ox,
|
||||
oy,
|
||||
};
|
||||
// Dispatch geometry (must match cursor_blend.comp): ARGB = per cursor px; NV12/YUV444 =
|
||||
// per word-aligned 4-px span × (2-row blocks | rows).
|
||||
let (gx, gy) = match fmt {
|
||||
SlotFormat::Argb => (cw.div_ceil(8), ch.div_ceil(8)),
|
||||
_ => {
|
||||
let x0 = (ox >> 2) << 2;
|
||||
let spans = ((ox + cw as i32) - x0 + 3).div_euclid(4).max(1) as u32;
|
||||
let rows = match fmt {
|
||||
SlotFormat::Nv12 => ch.div_ceil(2),
|
||||
_ => ch,
|
||||
};
|
||||
(spans.div_ceil(8), rows.div_ceil(8))
|
||||
}
|
||||
};
|
||||
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The descriptor
|
||||
// update is safe because no prior submission is in flight (every blend fence-waits and
|
||||
// the fence is reset before reuse). Buffer infos and barrier structs are locals outliving
|
||||
// their synchronous calls. The dispatch's shader accesses stay in-bounds by the shader's
|
||||
// own guards (surfW/surfH/curW/curH from `push`) against the slot allocation sized in
|
||||
// `alloc_slot` for exactly that geometry.
|
||||
// SAFETY: single-threaded record/submit/wait on handles this struct owns. The slot's
|
||||
// previous submission completed (`record_blend`'s contract: this path fence-waits every
|
||||
// blend, and an earlier ORDERED blend on this slot finished before the encoder reused it
|
||||
// — ring-reuse discipline). Submit-info arrays are locals outliving the synchronous
|
||||
// calls. The dispatch's shader accesses stay in-bounds by the shader's own guards
|
||||
// (surfW/surfH/curW/curH from `push`) against the slot allocation sized in `alloc_slot`
|
||||
// for exactly that geometry.
|
||||
unsafe {
|
||||
let d = &self.device;
|
||||
let surf_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(alloc.buffer)
|
||||
.offset(0)
|
||||
.range(alloc.size)];
|
||||
let cur_info = [vk::DescriptorBufferInfo::default()
|
||||
.buffer(self.cur_buf)
|
||||
.offset(0)
|
||||
.range((CURSOR_MAX * CURSOR_MAX * 4) as u64)];
|
||||
let writes = [
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(self.desc_set)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&surf_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(self.desc_set)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&cur_info),
|
||||
];
|
||||
d.update_descriptor_sets(&writes, &[]);
|
||||
|
||||
d.begin_command_buffer(
|
||||
self.cmd,
|
||||
&vk::CommandBufferBeginInfo::default()
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)
|
||||
.context("begin blend cmd")?;
|
||||
// CUDA wrote the frame into this memory outside Vulkan's view — make it visible to
|
||||
// the shader (external-memory coherence ceremony; NVIDIA honors this with the fence
|
||||
// ordering alone, the barrier is the spec-shaped belt-and-braces).
|
||||
let acquire = [vk::MemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_READ | vk::AccessFlags::SHADER_WRITE)];
|
||||
d.cmd_pipeline_barrier(
|
||||
self.cmd,
|
||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&acquire,
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
d.cmd_bind_pipeline(
|
||||
self.cmd,
|
||||
vk::PipelineBindPoint::COMPUTE,
|
||||
self.pipelines[fmt.mode() as usize],
|
||||
);
|
||||
d.cmd_bind_descriptor_sets(
|
||||
self.cmd,
|
||||
vk::PipelineBindPoint::COMPUTE,
|
||||
self.pipe_layout,
|
||||
0,
|
||||
&[self.desc_set],
|
||||
&[],
|
||||
);
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
(&push as *const Push) as *const u8,
|
||||
std::mem::size_of::<Push>(),
|
||||
);
|
||||
d.cmd_push_constants(
|
||||
self.cmd,
|
||||
self.pipe_layout,
|
||||
vk::ShaderStageFlags::COMPUTE,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
d.cmd_dispatch(self.cmd, gx.max(1), gy.max(1), 1);
|
||||
// Release the shader's writes so the post-fence CUDA/NVENC reads see them.
|
||||
let release = [vk::MemoryBarrier::default()
|
||||
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::MEMORY_READ)];
|
||||
d.cmd_pipeline_barrier(
|
||||
self.cmd,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||
vk::DependencyFlags::empty(),
|
||||
&release,
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
d.end_command_buffer(self.cmd).context("end blend cmd")?;
|
||||
let cmds = [self.cmd];
|
||||
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
|
||||
let cmds = [cmd];
|
||||
let submit = [vk::SubmitInfo::default().command_buffers(&cmds)];
|
||||
d.queue_submit(self.queue, &submit, self.fence)
|
||||
.context("submit blend")?;
|
||||
@@ -660,11 +904,121 @@ impl VkSlotBlend {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stream-ordered blend — no CPU sync anywhere (the fix for cursor frames serializing the
|
||||
/// NVENC submit path). The caller has ENQUEUED (not synced) its CUDA frame copy on this
|
||||
/// thread's copy stream; this method then
|
||||
/// 1. enqueues a CUDA signal of `copy_done` on that stream (fires once the copy completes),
|
||||
/// 2. submits the blend waiting `copy_done` and signaling `blend_done` on the Vulkan queue,
|
||||
/// 3. enqueues a CUDA wait of `blend_done` — so later stream work (the encode, via the
|
||||
/// session's IO-stream binding) orders after the blend's writes.
|
||||
///
|
||||
/// Timeline values are fresh per call and never reused. Failure leaves no dangling waiter:
|
||||
/// the CUDA wait is enqueued only after the submit was accepted, and an orphaned CUDA
|
||||
/// signal is legal (timeline signals may skip values — a later, larger signal satisfies
|
||||
/// any waiter).
|
||||
#[allow(clippy::too_many_arguments)] // same unpacked kernel args as `blend_ref`
|
||||
pub fn blend_ref_ordered(
|
||||
&mut self,
|
||||
slot: &VkSlotRef,
|
||||
fmt: SlotFormat,
|
||||
surf_w: u32,
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
ox: i32,
|
||||
oy: i32,
|
||||
) -> Result<()> {
|
||||
let Some((push, gx, gy)) = Self::blend_geometry(slot, fmt, surf_w, cw, ch, ox, oy) else {
|
||||
return Ok(());
|
||||
};
|
||||
let (copy_done, blend_done, sem) = {
|
||||
let t = self
|
||||
.timeline
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow!("ordered blend without timeline support"))?;
|
||||
t.ticket += 2;
|
||||
(t.ticket - 1, t.ticket, t.sem)
|
||||
};
|
||||
// Signal FIRST: if this fails nothing was submitted and the fresh values are simply
|
||||
// skipped. (The reverse order could wedge the queue — a submitted blend waiting on a
|
||||
// copy-done signal that never got enqueued.)
|
||||
self.timeline
|
||||
.as_ref()
|
||||
.expect("checked above")
|
||||
.cuda
|
||||
.signal(copy_done)
|
||||
.context("cuSignalExternalSemaphoresAsync (copy done)")?;
|
||||
// SAFETY: single-threaded record/submit on handles this struct owns. The slot's previous
|
||||
// submission completed (`record_blend`'s contract — ring-reuse discipline, see above).
|
||||
// All submit-info arrays and the timeline-submit chain are locals outliving the
|
||||
// synchronous `queue_submit`. No fence: completion is observed through the timeline
|
||||
// (the encode polls it via the CUDA wait below; teardown quiesces via
|
||||
// `device_wait_idle`).
|
||||
unsafe {
|
||||
let cmd = self.record_blend(slot.id, fmt, &push, gx, gy)?;
|
||||
let cmds = [cmd];
|
||||
let wait_sems = [sem];
|
||||
let wait_vals = [copy_done];
|
||||
let wait_stages = [vk::PipelineStageFlags::COMPUTE_SHADER];
|
||||
let sig_sems = [sem];
|
||||
let sig_vals = [blend_done];
|
||||
let mut tsi = vk::TimelineSemaphoreSubmitInfo::default()
|
||||
.wait_semaphore_values(&wait_vals)
|
||||
.signal_semaphore_values(&sig_vals);
|
||||
let submit = [vk::SubmitInfo::default()
|
||||
.command_buffers(&cmds)
|
||||
.wait_semaphores(&wait_sems)
|
||||
.wait_dst_stage_mask(&wait_stages)
|
||||
.signal_semaphores(&sig_sems)
|
||||
.push_next(&mut tsi)];
|
||||
self.device
|
||||
.queue_submit(self.queue, &submit, vk::Fence::null())
|
||||
.context("submit ordered blend")?;
|
||||
}
|
||||
let t = self.timeline.as_mut().expect("checked above");
|
||||
// Only now: `blend_done` WILL be signaled, so quiesces may rely on it.
|
||||
t.last_blend = blend_done;
|
||||
if let Err(e) = t.cuda.wait(blend_done) {
|
||||
// The blend is in flight but the encode would no longer order after it — restore
|
||||
// the ordering with a bounded CPU wait (the CPU-synced path's cost, once). The
|
||||
// frame still composites correctly.
|
||||
let sems = [t.sem];
|
||||
let values = [blend_done];
|
||||
// SAFETY: live timeline semaphore; wait-info arrays are locals outliving the
|
||||
// synchronous call; `blend_done` was accepted for signaling just above, so the
|
||||
// wait terminates (timeout backstops a wedged queue).
|
||||
let r = unsafe {
|
||||
t.ts.wait_semaphores(
|
||||
&vk::SemaphoreWaitInfo::default()
|
||||
.semaphores(&sems)
|
||||
.values(&values),
|
||||
1_000_000_000,
|
||||
)
|
||||
};
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
cpu_wait = ?r,
|
||||
"ordered cursor blend: CUDA-side wait enqueue failed — ordering restored with \
|
||||
a CPU wait this frame"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VkSlotBlend {
|
||||
fn drop(&mut self) {
|
||||
self.free_slots();
|
||||
if let Some(t) = self.timeline.take() {
|
||||
drop(t.cuda); // CUDA's import of the semaphore goes first (mirrors the slot order)
|
||||
// SAFETY: `sem` was created in `init_timeline`, is uniquely owned, and is destroyed
|
||||
// exactly once here. No submission still references it: ordered blends only exist
|
||||
// alongside allocated slots, and `free_slots` just quiesced (device_wait_idle) any
|
||||
// in-flight work before this point.
|
||||
unsafe {
|
||||
self.device.destroy_semaphore(t.sem, None);
|
||||
}
|
||||
}
|
||||
// SAFETY: every handle below was created in `new`/`init_objects` (or is null from a
|
||||
// partial init — Vulkan destroy/free calls are defined no-ops on null handles) and is
|
||||
// uniquely owned; each is destroyed exactly once here, pipelines/layouts/pools before the
|
||||
|
||||
@@ -52,8 +52,8 @@ pub(crate) struct Negotiated {
|
||||
/// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
|
||||
pub(crate) clock_offset_ns: i64,
|
||||
/// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
|
||||
/// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard
|
||||
/// compares each batch against this baseline ([`accept_resync`]).
|
||||
/// mid-stream re-syncs are pointless then and stay off. Seeds the re-sync admission
|
||||
/// guard's session-floor baseline ([`ResyncGuard`]).
|
||||
pub(crate) clock_rtt_ns: Option<u64>,
|
||||
/// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
|
||||
pub(crate) bit_depth: u8,
|
||||
|
||||
@@ -248,7 +248,16 @@ pub(crate) struct EncodeLatAcc {
|
||||
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
|
||||
/// its own, so a clump never costs a keyframe.
|
||||
///
|
||||
/// **All-intra exception** ([`set_all_intra`], PyroWave): every AU is independently decodable, so
|
||||
/// the reference-chain reasoning above does not apply — a consumer that falls behind can skip
|
||||
/// straight to the newest queued AU with zero recovery cost (no keyframe round-trip, no corrupt
|
||||
/// dependents). [`pop`] then drains to the newest instead of returning the oldest, which caps any
|
||||
/// standing queue at ~1 frame structurally; the 2026-07 field report's 780M client otherwise
|
||||
/// ratcheted a genuine multi-frame backlog between the coarse jump-to-live thresholds.
|
||||
///
|
||||
/// [`clear`]: FrameChannel::clear
|
||||
/// [`set_all_intra`]: FrameChannel::set_all_intra
|
||||
/// [`pop`]: FrameChannel::pop
|
||||
pub(crate) struct FrameChannel {
|
||||
inner: Mutex<FrameQueue>,
|
||||
ready: Condvar,
|
||||
@@ -259,6 +268,11 @@ struct FrameQueue {
|
||||
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
|
||||
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
|
||||
closed: bool,
|
||||
/// Every AU decodes independently (PyroWave): [`FrameChannel::pop`] drains to the newest.
|
||||
all_intra: bool,
|
||||
/// AUs skipped by the all-intra drain since the last [`FrameChannel::take_skipped`] — NOT
|
||||
/// losses (the wire delivered them); the pump surfaces them at debug on its report tick.
|
||||
skipped_total: u64,
|
||||
}
|
||||
|
||||
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
|
||||
@@ -275,11 +289,25 @@ impl FrameChannel {
|
||||
inner: Mutex::new(FrameQueue {
|
||||
q: VecDeque::new(),
|
||||
closed: false,
|
||||
all_intra: false,
|
||||
skipped_total: 0,
|
||||
}),
|
||||
ready: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump side, once at session start: mark the stream all-intra (every AU independently
|
||||
/// decodable) — [`Self::pop`] then drains to the newest queued AU instead of strict FIFO.
|
||||
pub(crate) fn set_all_intra(&self, all_intra: bool) {
|
||||
self.inner.lock().unwrap().all_intra = all_intra;
|
||||
}
|
||||
|
||||
/// Pump side: AUs skipped by the all-intra drain since the last call (reset on read).
|
||||
pub(crate) fn take_skipped(&self) -> u64 {
|
||||
let mut st = self.inner.lock().unwrap();
|
||||
std::mem::take(&mut st.skipped_total)
|
||||
}
|
||||
|
||||
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
|
||||
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
|
||||
/// already in flight by the time this can bite).
|
||||
@@ -312,12 +340,21 @@ impl FrameChannel {
|
||||
self.ready.notify_all();
|
||||
}
|
||||
|
||||
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive.
|
||||
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive. On an
|
||||
/// all-intra stream ([`Self::set_all_intra`]) a multi-deep queue drains to the NEWEST AU
|
||||
/// instead — the skipped ones are already superseded and decode independently, so showing
|
||||
/// them only adds latency.
|
||||
pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
|
||||
let mut st = self.inner.lock().unwrap();
|
||||
if st.q.is_empty() && !st.closed {
|
||||
st = self.ready.wait_timeout(st, timeout).unwrap().0;
|
||||
}
|
||||
if st.all_intra && st.q.len() > 1 {
|
||||
st.skipped_total += (st.q.len() - 1) as u64;
|
||||
let newest = st.q.pop_back().expect("len > 1");
|
||||
st.q.clear();
|
||||
return FramePop::Frame(newest);
|
||||
}
|
||||
if let Some(f) = st.q.pop_front() {
|
||||
FramePop::Frame(f)
|
||||
} else if st.closed {
|
||||
@@ -364,6 +401,25 @@ mod frame_channel_tests {
|
||||
assert_eq!(ch.depth(), 0);
|
||||
}
|
||||
|
||||
/// The all-intra exception: a multi-deep queue drains to the NEWEST AU (every frame
|
||||
/// decodes independently — older queued ones are superseded), the skips are counted
|
||||
/// separately from losses, and a single-deep queue behaves exactly like FIFO.
|
||||
#[test]
|
||||
fn all_intra_drains_to_newest_and_counts_skips() {
|
||||
let ch = FrameChannel::new();
|
||||
ch.set_all_intra(true);
|
||||
for i in 1..=3 {
|
||||
ch.push(frame(i));
|
||||
}
|
||||
assert_eq!(popped(&ch), Some(3));
|
||||
assert_eq!(ch.depth(), 0);
|
||||
assert_eq!(ch.take_skipped(), 2);
|
||||
assert_eq!(ch.take_skipped(), 0); // reset on read
|
||||
ch.push(frame(4));
|
||||
assert_eq!(popped(&ch), Some(4)); // depth 1 = plain FIFO, no skip accounting
|
||||
assert_eq!(ch.take_skipped(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pop_times_out_not_closed() {
|
||||
let ch = FrameChannel::new();
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::abr::BitrateController;
|
||||
use crate::config::Role;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
accept_resync, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho,
|
||||
ClockResync, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
|
||||
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
|
||||
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
|
||||
ResyncStep, SetBitrate, Start, Welcome,
|
||||
};
|
||||
use crate::session::Session;
|
||||
|
||||
@@ -52,6 +52,14 @@ impl ControlTask {
|
||||
// the read arm below; only when the host answered the connect-time handshake — an
|
||||
// old host would just eat the probes.
|
||||
let mut resync = ClockResync::new();
|
||||
let mut resync_guard = clock_rtt_ns.map(ResyncGuard::new);
|
||||
// Inter-round spacing: without it the whole 8-round batch completes inside one
|
||||
// ~6 ms video burst and every round samples the same congestion state — a batch
|
||||
// that starts mid-burst is then wholly congested and gets rejected. 7 ms staggers
|
||||
// the rounds across the ~16.7 ms frame cycle so the min-RTT round almost always
|
||||
// lands in a quiet inter-burst gap, even at PyroWave-class bitrates.
|
||||
const RESYNC_ROUND_SPACING: std::time::Duration = std::time::Duration::from_millis(7);
|
||||
let mut staged_round: Option<tokio::time::Instant> = None;
|
||||
let mut resync_tick = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
|
||||
CLOCK_RESYNC_INTERVAL,
|
||||
@@ -72,6 +80,7 @@ impl ControlTask {
|
||||
if clock_rtt_ns.is_none() {
|
||||
continue; // no connect-time handshake — host can't answer
|
||||
}
|
||||
staged_round = None; // a new batch abandons any staged round
|
||||
resync.begin(wall_clock_ns()).encode()
|
||||
}
|
||||
CtrlRequest::ClipControl(c) => c.encode(),
|
||||
@@ -83,11 +92,21 @@ impl ControlTask {
|
||||
}
|
||||
}
|
||||
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
|
||||
staged_round = None; // a new batch abandons any staged round
|
||||
let probe = resync.begin(wall_clock_ns());
|
||||
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = async { tokio::time::sleep_until(staged_round.unwrap()).await },
|
||||
if staged_round.is_some() => {
|
||||
staged_round = None;
|
||||
// Stamped at send time so the inter-round spacing stays out of the RTT.
|
||||
let probe = resync.next_probe(wall_clock_ns());
|
||||
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = ctrl_recv.read_msg() => {
|
||||
let Ok(msg) = msg else { break }; // stream closed
|
||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||
@@ -132,16 +151,36 @@ impl ControlTask {
|
||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||
} else if let Ok(echo) = ClockEcho::decode(&msg) {
|
||||
match resync.on_echo(&echo, wall_clock_ns()) {
|
||||
ResyncStep::Probe(p) => {
|
||||
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
ResyncStep::MoreRounds => {
|
||||
staged_round = Some(
|
||||
tokio::time::Instant::now() + RESYNC_ROUND_SPACING,
|
||||
);
|
||||
}
|
||||
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||
// Never let a congested window bias the offset (frames read
|
||||
// late exactly then) — keep the old estimate and let the next
|
||||
// periodic batch try again.
|
||||
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
|
||||
let Some(guard) = resync_guard.as_mut() else {
|
||||
continue; // no connect handshake — batches never start
|
||||
};
|
||||
let (apply, best_of_streak) = match guard.admit(offset_ns, rtt_ns)
|
||||
{
|
||||
ResyncAdmit::Fresh => (Some((offset_ns, rtt_ns)), false),
|
||||
ResyncAdmit::BestOfStreak { offset_ns, rtt_ns } => {
|
||||
(Some((offset_ns, rtt_ns)), true)
|
||||
}
|
||||
ResyncAdmit::Rejected { streak } => {
|
||||
// warn, not debug: repeated rejections are exactly the
|
||||
// stale-offset starvation signature the 2026-07
|
||||
// PyroWave-sawtooth report had to be diagnosed without.
|
||||
tracing::warn!(
|
||||
rtt_us = rtt_ns / 1000,
|
||||
floor_us = guard.floor_rtt_ns() / 1000,
|
||||
streak,
|
||||
"clock re-sync batch rejected — RTT above the \
|
||||
session floor (congested window)"
|
||||
);
|
||||
(None, false)
|
||||
}
|
||||
};
|
||||
if let Some((offset_ns, rtt_ns)) = apply {
|
||||
// info, not debug: ≤1/min, and it is THE forensic
|
||||
// trail for a stale-offset (stepped/slewed wall clock)
|
||||
// latency plateau — the 2026-07 two-pair investigation
|
||||
@@ -149,16 +188,11 @@ impl ControlTask {
|
||||
tracing::info!(
|
||||
offset_ns,
|
||||
rtt_us = rtt_ns / 1000,
|
||||
best_of_streak,
|
||||
"mid-stream clock re-sync applied"
|
||||
);
|
||||
clock_offset.store(offset_ns, Ordering::Relaxed);
|
||||
clock_gen.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
tracing::info!(
|
||||
rtt_us = rtt_ns / 1000,
|
||||
"clock re-sync batch discarded — RTT above the \
|
||||
connect-time baseline (congested window)"
|
||||
);
|
||||
}
|
||||
}
|
||||
ResyncStep::Idle => {}
|
||||
|
||||
@@ -87,6 +87,10 @@ impl DataPump {
|
||||
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
|
||||
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
|
||||
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
|
||||
// All-intra streams have no reference chains: the frame channel drains to the newest
|
||||
// AU instead of strict FIFO (see `FrameChannel::set_all_intra`), so a slow consumer
|
||||
// caps its standing queue at ~1 frame with zero recovery cost.
|
||||
frames.set_all_intra(negotiated_codec == crate::quic::CODEC_PYROWAVE);
|
||||
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
|
||||
resolved_bitrate_kbps
|
||||
} else {
|
||||
@@ -114,6 +118,15 @@ impl DataPump {
|
||||
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
|
||||
// finished one left the ABR window anchored before the burst.
|
||||
let mut was_probing = false;
|
||||
// Set when a probe ends: the FIRST post-probe report window is discarded outright (no
|
||||
// LossReport, no standing-latency close, no ABR feed). The `last_*` rebase below cannot
|
||||
// fully clean it — probe frames still pending in the reassembler age out as
|
||||
// `frames_dropped` for another LOSS_WINDOW (~120 ms) AFTER the rebase, and the burst may
|
||||
// have latched `flush_in_window` — and either reads as SEVERE congestion. The 2026-07
|
||||
// field report's Automatic session backed off 20→14 Mb/s one second in (exactly one
|
||||
// report tick after its capacity probe) and, with slow start dead from that first
|
||||
// "congestion", crawled additively for the entire match.
|
||||
let mut discard_abr_window = false;
|
||||
let mut probe_watchdog: Option<Instant> = None;
|
||||
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
|
||||
let mut flush_in_window = false;
|
||||
@@ -193,6 +206,8 @@ impl DataPump {
|
||||
last_dropped = st.frames_dropped;
|
||||
last_bytes = st.bytes_received;
|
||||
last_report = Instant::now();
|
||||
discard_abr_window = true;
|
||||
flush_in_window = false;
|
||||
}
|
||||
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
|
||||
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
|
||||
@@ -289,6 +304,14 @@ impl DataPump {
|
||||
resync_wanted = false;
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
|
||||
}
|
||||
// All-intra drain-to-newest skips are NOT losses (the wire delivered them) —
|
||||
// surface them at debug so a slow consumer is visible without alarming the
|
||||
// OSD loss counters.
|
||||
let skipped = frames.take_skipped();
|
||||
if skipped > 0 {
|
||||
tracing::debug!(skipped, "all-intra frame channel drained to newest");
|
||||
}
|
||||
let discard = std::mem::take(&mut discard_abr_window);
|
||||
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
||||
let loss_ppm = window_loss_ppm(
|
||||
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||
@@ -296,14 +319,26 @@ impl DataPump {
|
||||
st.packets_received.wrapping_sub(last_received),
|
||||
window_dropped,
|
||||
);
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
if discard {
|
||||
// Probe-tail residue (see `discard_abr_window`): a LossReport from this
|
||||
// window would also spike the host's adaptive FEC off deliberate overload.
|
||||
tracing::debug!(
|
||||
loss_ppm,
|
||||
window_dropped,
|
||||
"discarding the first post-probe ABR window (probe-tail residue)"
|
||||
);
|
||||
} else {
|
||||
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||
}
|
||||
// Standing-latency bleed: close the detector's window with this report's loss
|
||||
// verdict and run its escalation ladder — re-sync first (free; a stale offset
|
||||
// from a stepped wall clock produces exactly this signature and the applied
|
||||
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
|
||||
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
|
||||
// loud disarm (the path latency itself changed; nothing local fixes that).
|
||||
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
|
||||
// A discard window closes the detector as NOT-loss-free: its clean-run resets
|
||||
// (conservative) and no action can fire off probe residue.
|
||||
match standing_lat.on_window(!discard && loss_ppm == 0 && window_dropped == 0) {
|
||||
StandingLatAction::None => {}
|
||||
StandingLatAction::Resync { above_ms } => {
|
||||
tracing::info!(
|
||||
@@ -386,16 +421,23 @@ impl DataPump {
|
||||
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
|
||||
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
|
||||
/ window_ms) as u32;
|
||||
if let Some(kbps) = abr.on_window(
|
||||
Instant::now(),
|
||||
window_dropped,
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
) {
|
||||
// A discard window feeds the controller NOTHING — its signals are probe-tail
|
||||
// residue, and one "congestion" verdict here ends slow start for good.
|
||||
let verdict = if discard {
|
||||
None
|
||||
} else {
|
||||
abr.on_window(
|
||||
Instant::now(),
|
||||
window_dropped,
|
||||
loss_ppm,
|
||||
owd_mean_us,
|
||||
decode_mean_us,
|
||||
encode_mean_us,
|
||||
actual_kbps,
|
||||
flush_in_window,
|
||||
)
|
||||
};
|
||||
if let Some(kbps) = verdict {
|
||||
// Log the window's signals alongside the decision so an on-glass session can
|
||||
// tell a decode-/encode-driven re-target (the new signals — elevated with
|
||||
// loss/OWD flat) from a network-driven one.
|
||||
|
||||
@@ -82,8 +82,12 @@ pub fn wall_clock_ns() -> u64 {
|
||||
pub enum ResyncStep {
|
||||
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
|
||||
Idle,
|
||||
/// Send this next-round probe and keep feeding echoes.
|
||||
Probe(ClockProbe),
|
||||
/// The round was recorded and the batch wants another: wait the inter-round spacing, then
|
||||
/// stamp + send [`ClockResync::next_probe`]. Spacing the rounds makes the batch sample
|
||||
/// several phases of the periodic video-burst cycle instead of completing inside one burst
|
||||
/// — at high bitrates the whole 8-round batch otherwise fits in a single ~6 ms burst and
|
||||
/// every round reads the same congested (or same quiet) instant.
|
||||
MoreRounds,
|
||||
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
|
||||
Done { offset_ns: i64, rtt_ns: u64 },
|
||||
}
|
||||
@@ -118,6 +122,13 @@ impl ClockResync {
|
||||
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
|
||||
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
|
||||
self.samples.clear();
|
||||
self.next_probe(now_ns)
|
||||
}
|
||||
|
||||
/// Stamp + arm the next round's probe at `now_ns` — send it immediately. Called after
|
||||
/// [`ResyncStep::MoreRounds`] once the caller's inter-round spacing has elapsed; stamping
|
||||
/// at send time keeps that spacing out of the measured RTT.
|
||||
pub fn next_probe(&mut self, now_ns: u64) -> ClockProbe {
|
||||
self.pending_t1 = Some(now_ns);
|
||||
ClockProbe { t1_ns: now_ns }
|
||||
}
|
||||
@@ -129,11 +140,12 @@ impl ClockResync {
|
||||
}
|
||||
self.samples
|
||||
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
|
||||
if self.samples.len() < Self::ROUNDS {
|
||||
self.pending_t1 = Some(now_ns);
|
||||
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
|
||||
}
|
||||
// No probe in flight until the driver arms the next round (or a batch restarts) — a
|
||||
// duplicate of this round's echo must not double-record.
|
||||
self.pending_t1 = None;
|
||||
if self.samples.len() < Self::ROUNDS {
|
||||
return ResyncStep::MoreRounds;
|
||||
}
|
||||
match clock_offset_ns(&self.samples) {
|
||||
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
|
||||
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
|
||||
@@ -147,12 +159,97 @@ impl Default for ClockResync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
|
||||
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
|
||||
/// biases the offset by its queueing delay, and frames already read late exactly then; better
|
||||
/// to keep the old estimate and let the next batch try again.
|
||||
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
|
||||
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
|
||||
/// Acceptance predicate for a re-sync batch: its min RTT must be comparable to the best RTT
|
||||
/// this session has evidenced — `≤ max(2 ms, 1.5 × floor)`. A congested window biases the
|
||||
/// offset by its queueing delay, and frames already read late exactly then; better to keep the
|
||||
/// old estimate and let the next batch try again.
|
||||
pub fn accept_resync(batch_rtt_ns: u64, floor_rtt_ns: u64) -> bool {
|
||||
batch_rtt_ns <= (floor_rtt_ns + floor_rtt_ns / 2).max(2_000_000)
|
||||
}
|
||||
|
||||
/// Admission decision for a completed re-sync batch (see [`ResyncGuard::admit`]).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ResyncAdmit {
|
||||
/// Batch RTT is within the guard band of the session floor: apply this batch's offset.
|
||||
Fresh,
|
||||
/// Batch rejected (congested window) — keep the previous offset; `streak` counts the
|
||||
/// consecutive rejections since the last applied batch.
|
||||
Rejected { streak: u32 },
|
||||
/// The rejection streak hit [`ResyncGuard::MAX_REJECTED_STREAK`]: apply the best (min-RTT)
|
||||
/// batch of the streak instead of drifting further. Carries that batch's estimate.
|
||||
BestOfStreak { offset_ns: i64, rtt_ns: u64 },
|
||||
}
|
||||
|
||||
/// Admission control for mid-stream re-sync batches. Two fixes over the original static
|
||||
/// `≤ max(2 ms, 1.5 × connect RTT)` guard (2026-07 PyroWave-sawtooth field report):
|
||||
///
|
||||
/// - **The baseline is the session floor, not the connect-time RTT.** The connect handshake
|
||||
/// runs before the video data plane exists; comparing loaded mid-stream batches against that
|
||||
/// idle figure rejected essentially every batch of a high-bitrate LAN session, and the
|
||||
/// offset went stale while the wall clocks drifted apart — the OSD latency ramped for
|
||||
/// minutes and snapped back only when a lucky batch landed. The floor now folds in every
|
||||
/// completed batch's min RTT (rejected ones included: their min-RTT round is still floor
|
||||
/// evidence), so the baseline tracks what this path can actually do under load.
|
||||
/// - **Staleness is bounded.** After [`Self::MAX_REJECTED_STREAK`] consecutive rejections the
|
||||
/// best batch of the streak is applied anyway: its queueing bias is at most ~half its RTT
|
||||
/// (a few ms), while unbounded wall-clock drift is worth that many ms *per minute* on a
|
||||
/// slewing clock. A bounded bias beats an unbounded drift.
|
||||
pub struct ResyncGuard {
|
||||
/// Best RTT this session has evidenced: connect-time RTT, then min over every batch.
|
||||
floor_rtt_ns: u64,
|
||||
rejected_streak: u32,
|
||||
/// Min-RTT batch among the current rejection streak.
|
||||
best_pending: Option<(i64, u64)>,
|
||||
}
|
||||
|
||||
impl ResyncGuard {
|
||||
/// Consecutive rejected batches tolerated before the best of them is applied anyway.
|
||||
pub const MAX_REJECTED_STREAK: u32 = 3;
|
||||
|
||||
pub fn new(connect_rtt_ns: u64) -> ResyncGuard {
|
||||
ResyncGuard {
|
||||
floor_rtt_ns: connect_rtt_ns,
|
||||
rejected_streak: 0,
|
||||
best_pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current baseline the guard compares batches against (log/debug surface).
|
||||
pub fn floor_rtt_ns(&self) -> u64 {
|
||||
self.floor_rtt_ns
|
||||
}
|
||||
|
||||
/// Judge a completed batch. The caller applies the offset on [`ResyncAdmit::Fresh`] (this
|
||||
/// batch's) or [`ResyncAdmit::BestOfStreak`] (the carried one) and keeps the old offset on
|
||||
/// [`ResyncAdmit::Rejected`].
|
||||
pub fn admit(&mut self, offset_ns: i64, rtt_ns: u64) -> ResyncAdmit {
|
||||
// Judge against the floor as evidenced BEFORE this batch, then fold this batch in —
|
||||
// comparing a batch against a floor that already includes it would accept everything.
|
||||
let fresh = accept_resync(rtt_ns, self.floor_rtt_ns);
|
||||
self.floor_rtt_ns = self.floor_rtt_ns.min(rtt_ns);
|
||||
if fresh {
|
||||
self.rejected_streak = 0;
|
||||
self.best_pending = None;
|
||||
return ResyncAdmit::Fresh;
|
||||
}
|
||||
let best = match self.best_pending {
|
||||
Some((o, r)) if r <= rtt_ns => (o, r),
|
||||
_ => (offset_ns, rtt_ns),
|
||||
};
|
||||
self.best_pending = Some(best);
|
||||
self.rejected_streak += 1;
|
||||
if self.rejected_streak >= Self::MAX_REJECTED_STREAK {
|
||||
self.rejected_streak = 0;
|
||||
self.best_pending = None;
|
||||
return ResyncAdmit::BestOfStreak {
|
||||
offset_ns: best.0,
|
||||
rtt_ns: best.1,
|
||||
};
|
||||
}
|
||||
ResyncAdmit::Rejected {
|
||||
streak: self.rejected_streak,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -218,9 +315,14 @@ mod tests {
|
||||
let echo = echo_for(probe.t1_ns, one_way);
|
||||
let t4 = t4_for(&echo, one_way);
|
||||
match rs.on_echo(&echo, t4) {
|
||||
ResyncStep::Probe(p) => {
|
||||
ResyncStep::MoreRounds => {
|
||||
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
|
||||
probe = p;
|
||||
// A duplicate of the just-consumed echo must not double-record: no probe
|
||||
// is in flight until the driver arms the next round.
|
||||
assert_eq!(rs.on_echo(&echo, t4), ResyncStep::Idle);
|
||||
// The driver stamps the next probe at SEND time (after its inter-round
|
||||
// spacing), so the spacing never lands in the measured RTT.
|
||||
probe = rs.next_probe(t4 + 7_000_000);
|
||||
}
|
||||
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
|
||||
@@ -243,10 +345,46 @@ mod tests {
|
||||
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
|
||||
ResyncStep::Idle
|
||||
);
|
||||
assert!(matches!(
|
||||
assert_eq!(
|
||||
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
|
||||
ResyncStep::Probe(_)
|
||||
));
|
||||
ResyncStep::MoreRounds
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard's two field-report fixes: the baseline tracks the SESSION floor (a batch
|
||||
/// better than the stale connect figure re-anchors it), and a rejection streak is bounded
|
||||
/// — the best batch of the streak is applied rather than letting the offset go stale
|
||||
/// while the wall clocks drift apart.
|
||||
#[test]
|
||||
fn resync_guard_floor_tracking_and_bounded_streak() {
|
||||
// Connect measured 400 µs idle; the 2 ms floor of accept_resync governs early on.
|
||||
let mut g = ResyncGuard::new(400_000);
|
||||
assert_eq!(g.admit(10, 1_500_000), ResyncAdmit::Fresh);
|
||||
// A better batch lowers the floor evidence.
|
||||
assert_eq!(g.admit(11, 300_000), ResyncAdmit::Fresh);
|
||||
assert_eq!(g.floor_rtt_ns(), 300_000);
|
||||
|
||||
// Loaded stretch: batches at 4–6 ms all exceed max(2 ms, 1.5 × 300 µs).
|
||||
assert_eq!(g.admit(100, 6_000_000), ResyncAdmit::Rejected { streak: 1 });
|
||||
// The best (min-RTT) batch of the streak is remembered…
|
||||
assert_eq!(g.admit(200, 4_000_000), ResyncAdmit::Rejected { streak: 2 });
|
||||
// …and applied when the streak hits the cap — offset 200 (the 4 ms batch), not 300.
|
||||
assert_eq!(
|
||||
g.admit(300, 5_000_000),
|
||||
ResyncAdmit::BestOfStreak {
|
||||
offset_ns: 200,
|
||||
rtt_ns: 4_000_000
|
||||
}
|
||||
);
|
||||
// The streak reset: the next congested batch starts a new one.
|
||||
assert_eq!(g.admit(400, 5_000_000), ResyncAdmit::Rejected { streak: 1 });
|
||||
// A quiet batch clears it and applies normally.
|
||||
assert_eq!(g.admit(500, 350_000), ResyncAdmit::Fresh);
|
||||
|
||||
// A batch that IS the new floor is always fresh (compared against the pre-batch floor).
|
||||
let mut g2 = ResyncGuard::new(10_000_000);
|
||||
assert_eq!(g2.admit(1, 8_000_000), ResyncAdmit::Fresh);
|
||||
assert_eq!(g2.floor_rtt_ns(), 8_000_000);
|
||||
}
|
||||
|
||||
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
|
||||
|
||||
@@ -260,14 +260,13 @@ pub struct LeaseRequest {
|
||||
/// The reference instant for adopting this launch's processes, in seconds since boot. Call it
|
||||
/// **before** anything spawns; see [`LeaseRequest::launch_stamp`].
|
||||
pub fn launch_clock() -> Option<f64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
crate::procscan::launch_stamp()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
// Delegate unconditionally: `procscan::launch_stamp` already answers `None` on a platform with
|
||||
// no matcher. Gating this on Linux here silently disabled the start-time floor on Windows —
|
||||
// `find(spec, None)` skips the filter entirely — so a copy of the game the player already had
|
||||
// open was adopted and then ended with the session (caught on glass, .173: Steam focused a
|
||||
// running instance instead of starting one, and the lease took it). The Windows matcher is
|
||||
// exactly where that rule matters most, since the host is SYSTEM and can see every process.
|
||||
crate::procscan::launch_stamp()
|
||||
}
|
||||
|
||||
/// What the watcher does when it concludes the game has exited.
|
||||
@@ -466,7 +465,19 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
|
||||
|
||||
// The child being alive counts as the game running for a `Child` lease, so a title with no
|
||||
// detect signals is still fully tracked.
|
||||
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
|
||||
//
|
||||
// But a launcher that is about to hand off and exit looks *exactly* like the game for its
|
||||
// first few seconds. When the store gave us signals to recognize the real game by, wait out
|
||||
// the shim window before believing this child is it — otherwise the lease leaves this phase
|
||||
// on its very first poll, the reclassification above never gets to run, and the hand-off
|
||||
// that follows is read as the game exiting. On Linux that ended a session ~7 s after
|
||||
// launching any Steam title, before the game had even started (on glass, .41).
|
||||
//
|
||||
// With no signals the child is all we have, so it still counts immediately: a custom command
|
||||
// is tracked exactly as before.
|
||||
let child_alive = matches!(kind, LeaseKind::Child)
|
||||
&& child.is_some()
|
||||
&& (shared.spec.is_empty() || spawned_at.elapsed() >= SHIM_WINDOW);
|
||||
let live = scanner.find(&shared.spec, shared.launch_stamp);
|
||||
if !live.is_empty() || child_alive {
|
||||
known = live.clone();
|
||||
@@ -1136,6 +1147,59 @@ mod tests {
|
||||
assert_eq!(readopt(Some("fp-151"), Some(b)), 1);
|
||||
}
|
||||
|
||||
/// A launcher that hands off and exits must never be mistaken for the game.
|
||||
///
|
||||
/// This is the `steam steam://rungameid/…` shape: the host spawns a launcher as its own child,
|
||||
/// the launcher tells the already-running Steam to start the game and exits within a couple of
|
||||
/// seconds, and the game itself appears later (or, here, never). Ending the session on that exit
|
||||
/// is the failure this guards — it killed Linux Steam launches ~7 s in, before the game started.
|
||||
///
|
||||
/// The spec deliberately matches nothing: what is asserted is that a *quick, successful* child
|
||||
/// exit is treated as a hand-off to wait out, not as the game being gone.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_launcher_that_hands_off_and_exits_is_not_the_game_exiting() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
static EXITS: AtomicUsize = AtomicUsize::new(0);
|
||||
EXITS.store(0, Ordering::SeqCst);
|
||||
let child = std::process::Command::new("/bin/true")
|
||||
.spawn()
|
||||
.expect("spawn the fake launcher");
|
||||
let lease = open(
|
||||
LeaseRequest {
|
||||
game: GameRef {
|
||||
id: Some("steam:999001".into()),
|
||||
store: Some("steam".into()),
|
||||
title: "Handoff".into(),
|
||||
},
|
||||
client: "test".into(),
|
||||
plane: crate::events::Plane::Native,
|
||||
// A real signal that no process will ever match — the game never shows up.
|
||||
spec: DetectSpec::steam(999_001),
|
||||
nested: false,
|
||||
child: Some((child, false)),
|
||||
launch_stamp: None,
|
||||
},
|
||||
Box::new(|| {
|
||||
EXITS.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
// Past the shim window plus the exit-confirmation window: comfortably longer than the buggy
|
||||
// path took to declare the game gone.
|
||||
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(2));
|
||||
assert_eq!(
|
||||
EXITS.load(Ordering::SeqCst),
|
||||
0,
|
||||
"a launcher handing off must not end the session"
|
||||
);
|
||||
assert_ne!(
|
||||
lease.shared().state(),
|
||||
GameState::Exited,
|
||||
"the game never ran, so it cannot have exited"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
|
||||
/// notices when it exits, and reports that exit exactly once.
|
||||
///
|
||||
@@ -1182,13 +1246,19 @@ mod tests {
|
||||
let shared = lease.shared();
|
||||
assert!(matches!(shared.kind(), LeaseKind::Child));
|
||||
|
||||
// Seen running on the first poll, because the host holds the child.
|
||||
std::thread::sleep(Duration::from_millis(1_500));
|
||||
assert_eq!(shared.state(), GameState::Running, "should be running");
|
||||
// This script `exec`s a binary OUTSIDE the install dir, so neither the image path nor the
|
||||
// command line carries the directory: the host's own child is the only signal there is.
|
||||
// Which means the shim window gates it — a live child that might still turn out to be a
|
||||
// launcher is not called the game until that window has passed. Waiting it out is the point
|
||||
// of the assertion, not an accident of timing.
|
||||
std::thread::sleep(SHIM_WINDOW + Duration::from_millis(1_500));
|
||||
assert_eq!(
|
||||
shared.state(),
|
||||
GameState::Running,
|
||||
"a child that outlives the shim window IS the game"
|
||||
);
|
||||
assert_eq!(EXITS.load(Ordering::SeqCst), 0, "nothing has exited yet");
|
||||
|
||||
// Past the shim window, so its exit counts as the game's rather than a launcher handing off.
|
||||
std::thread::sleep(SHIM_WINDOW);
|
||||
terminate(shared.clone(), "test asked");
|
||||
|
||||
// The ladder asks politely first; `sleep` dies on SIGTERM, so this resolves well inside the
|
||||
@@ -1207,6 +1277,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Wherever the host can match processes at all, a launch MUST have a reference instant to
|
||||
/// match them against — it is the only thing standing between this feature and ending a copy of
|
||||
/// the game the player already had open. `None` here doesn't fail loudly; it silently turns the
|
||||
/// filter off ([`crate::procscan::Scanner::find`] skips it), so nothing downstream can notice.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
#[test]
|
||||
fn a_launch_always_gets_a_reference_instant_to_adopt_against() {
|
||||
assert!(
|
||||
launch_clock().is_some(),
|
||||
"no launch reference on a platform that matches processes — every pre-existing \
|
||||
instance of a title would be adoptable, and endable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_strings_are_stable() {
|
||||
// These reach the API and the console; renaming one is a wire change.
|
||||
|
||||
@@ -78,6 +78,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
// One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect.
|
||||
let mut hdr_sent = false;
|
||||
let mut peer: Option<PeerID> = None;
|
||||
// The session's GCM key, remembered from the last tick it was live. Ending a session
|
||||
// clears `launch` — and the key lives there — so without this copy the one message that
|
||||
// has to go out *because* the session ended could no longer be sealed.
|
||||
let mut last_key: Option<[u8; 16]> = None;
|
||||
loop {
|
||||
loop {
|
||||
match host.service() {
|
||||
@@ -154,6 +158,56 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A session can also end from the HOST side: the launched game exited, the operator
|
||||
// stopped it, or `/cancel` arrived on another connection. Tearing the media threads
|
||||
// down is *silence*, not a signal — Moonlight holds this control stream for the
|
||||
// whole session, so with no word from us it sits on its last frame until its own
|
||||
// timeout eventually fires. The client froze instead of ending (on-glass, .173).
|
||||
//
|
||||
// Disconnecting the peer is how it learns. Moonlight treats a control-stream
|
||||
// disconnect as the session being over and returns to its app list — the same place
|
||||
// it lands when the user quits, which is exactly where "the game exited" should
|
||||
// leave them.
|
||||
//
|
||||
// Merely dropping the peer is not enough either: the client reports that as a
|
||||
// connection error (`-1` on glass, .173), because an ENet disconnect on its own is
|
||||
// indistinguishable from the host falling over. The protocol has a word for this —
|
||||
// a TERMINATION control message — so send that first and let the disconnect follow.
|
||||
//
|
||||
// `end_session` clears `launch`, so a tracked peer with no launch behind it *is*
|
||||
// the ended session. Clearing `peer` first makes this fire once; the real
|
||||
// `Disconnect` event that follows then takes the non-session-peer branch, which is
|
||||
// why this arm repeats that branch's per-connection cleanup.
|
||||
if let Some(pid) = peer {
|
||||
let ended = state
|
||||
.launch
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_none();
|
||||
if ended {
|
||||
// Only sealable once the client's scheme is known; without it, fall through
|
||||
// to the bare disconnect rather than send a packet it can't read.
|
||||
if let (Some(scheme), Some(key)) = (detected, last_key) {
|
||||
let pt = termination_plaintext();
|
||||
let wire = encrypt_control(&key, &scheme, host_seq, &pt);
|
||||
host_seq = host_seq.wrapping_add(1);
|
||||
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
||||
{
|
||||
tracing::warn!(error = ?e, "control: termination send failed");
|
||||
}
|
||||
}
|
||||
tracing::info!("control: the session ended — telling the client");
|
||||
// `disconnect_later` flushes the queued termination first; a plain
|
||||
// `disconnect` would race it off the wire and land us back at "-1".
|
||||
host.peer_mut(pid).disconnect_later(0);
|
||||
peer = None;
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
hdr_sent = false;
|
||||
pads = GamepadManager::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
}
|
||||
}
|
||||
// Service the pads' force-feedback protocol every tick (games block inside
|
||||
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
|
||||
//
|
||||
@@ -171,6 +225,10 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
// use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD).
|
||||
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
||||
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
||||
// Remember it for the teardown message (see `last_key`).
|
||||
if key.is_some() {
|
||||
last_key = key;
|
||||
}
|
||||
if let Some(key) = key {
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
// One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the
|
||||
@@ -547,6 +605,42 @@ fn hdr_mode_plaintext(enabled: bool, m: &HdrMeta) -> Vec<u8> {
|
||||
pt
|
||||
}
|
||||
|
||||
/// Build the host→client TERMINATION control plaintext — "the session is over, on purpose".
|
||||
///
|
||||
/// Without it, ending a session host-side is invisible on the wire: the media threads simply stop,
|
||||
/// which the client cannot tell from a host that fell over, so it either sits on its last frame or
|
||||
/// (once we disconnect the peer) reports a connection error. This is the message that makes it a
|
||||
/// clean end, and the client returns to its app list.
|
||||
///
|
||||
/// Verified against moonlight-common-c `ControlStream.c` (master, 2026-07-26):
|
||||
///
|
||||
/// * **Type `0x0109`**, from `packetTypesGen7Enc[IDX_TERMINATION]`. Which table the client reads is
|
||||
/// decided by ONE thing — the version we advertise:
|
||||
/// `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and
|
||||
/// [`super::APP_VERSION`] is exactly `7.1.431`. So every client is on the encrypted table, where
|
||||
/// termination is `0x0109`; the plain table's `0x0100` would be ignored.
|
||||
///
|
||||
/// This is *not* the same axis as [`NonceKind`], which only describes how the GCM nonce is built.
|
||||
/// Deriving the type from the nonce scheme looks reasonable and is wrong — it sent `0x0100` to a
|
||||
/// client reading the encrypted table, which ignored it silently and reported the session as `-1`
|
||||
/// (on glass, .173). The HDR message could never have caught this: `0x010e` in both tables.
|
||||
/// * **Payload** is a big-endian `u32` reason (the ≥6-byte "extended" branch; the short branch is a
|
||||
/// little-endian `u16`, which is GFE's older shape).
|
||||
/// * **`0x80030023`** is `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, which the client maps to
|
||||
/// `ML_ERROR_GRACEFUL_TERMINATION` *provided it has seen a frame* — true for any real session,
|
||||
/// and the reason this reads as "the app quit" rather than an error.
|
||||
fn termination_plaintext() -> Vec<u8> {
|
||||
/// `packetTypesGen7Enc[IDX_TERMINATION]` — see the version gate above.
|
||||
const TERMINATION: u16 = 0x0109;
|
||||
/// `NVST_DISCONN_SERVER_TERMINATED_CLOSED` — a deliberate, graceful host-side end.
|
||||
const GRACEFUL: u32 = 0x8003_0023;
|
||||
let mut pt = Vec::with_capacity(8);
|
||||
pt.extend_from_slice(&TERMINATION.to_le_bytes());
|
||||
pt.extend_from_slice(&4u16.to_le_bytes()); // length = the reason that follows
|
||||
pt.extend_from_slice(&GRACEFUL.to_be_bytes()); // big-endian: the extended branch
|
||||
pt
|
||||
}
|
||||
|
||||
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
|
||||
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
|
||||
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
|
||||
@@ -648,6 +742,41 @@ mod tests {
|
||||
assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first
|
||||
}
|
||||
|
||||
/// The termination plaintext is what turns "the host went quiet" into "the app quit" on the
|
||||
/// client. Getting the type from the wrong table, or the reason's byte order wrong, is silently
|
||||
/// ignored by the client — which reads on glass as a frozen stream or a `-1` error, not as a
|
||||
/// failed test. Pinned against moonlight-common-c `ControlStream.c`.
|
||||
#[test]
|
||||
fn termination_plaintext_wire_layout() {
|
||||
let pt = super::termination_plaintext();
|
||||
assert_eq!(pt.len(), 8);
|
||||
// The ENCRYPTED table's entry — which is the one every client reads, see below.
|
||||
assert_eq!(&pt[0..2], &0x0109u16.to_le_bytes());
|
||||
assert_eq!(&pt[2..4], &4u16.to_le_bytes());
|
||||
// The reason is BIG-endian: the client's >=6-byte "extended" branch.
|
||||
assert_eq!(&pt[4..8], &0x8003_0023u32.to_be_bytes());
|
||||
}
|
||||
|
||||
/// The termination type above is only correct because of the version we advertise:
|
||||
/// moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and that
|
||||
/// alone decides whether the client reads `packetTypesGen7Enc` (termination `0x0109`) or
|
||||
/// `packetTypesGen7` (`0x0100`). Drop below that version and the message silently stops being
|
||||
/// understood — the stream would end as an error again, with nothing failing here to say why.
|
||||
#[test]
|
||||
fn advertised_version_keeps_the_client_on_the_encrypted_packet_table() {
|
||||
let q: Vec<i32> = super::super::APP_VERSION
|
||||
.split('.')
|
||||
.map(|p| p.parse().unwrap_or(0))
|
||||
.collect();
|
||||
let at_least = q[0] > 7 || (q[0] == 7 && (q[1] > 1 || (q[1] == 1 && q[2] >= 431)));
|
||||
assert!(
|
||||
at_least,
|
||||
"APP_VERSION {} is below 7.1.431, so clients read the PLAIN packet table and \
|
||||
termination must become 0x0100",
|
||||
super::super::APP_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/// The HDR-mode plaintext must match what moonlight-common-c parses: `[u16 type=0x010e]
|
||||
/// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B.
|
||||
#[test]
|
||||
|
||||
@@ -85,6 +85,57 @@ pub struct LaunchSpec {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Descriptive metadata for a title — everything a richer library UI (details pane, platform
|
||||
/// filter, couch-pick badges) renders beyond the poster. Every field is optional and defaults to
|
||||
/// absent, so pre-metadata catalogs, providers, and clients keep working unchanged. The struct is
|
||||
/// `#[serde(flatten)]`-ed into [`GameEntry`] / the custom-store shapes: one definition, a flat
|
||||
/// wire shape everywhere.
|
||||
///
|
||||
/// Values are free-form display strings, not enums — emulation sources (RomM, EmuDeck, Playnite)
|
||||
/// each have their own vocabulary and the host has no business normalizing it.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, ToSchema)]
|
||||
pub struct GameMeta {
|
||||
/// The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … Installed-store
|
||||
/// scanners stamp `"PC"`; `GET /library?platform=` filters on it (case-insensitive).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(example = "PS2")]
|
||||
pub platform: Option<String>,
|
||||
/// Short blurb for a details pane.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub developer: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publisher: Option<String>,
|
||||
/// Year of first release — the granularity metadata sources reliably agree on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(example = 2001)]
|
||||
pub release_year: Option<u16>,
|
||||
/// Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub genres: Vec<String>,
|
||||
/// Free-form organizational labels (`"co-op"`, `"kids"`, `"finished"`, …).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
/// Release region — emulation-relevant (`"NTSC-U"`, `"PAL"`, `"NTSC-J"`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
/// Maximum simultaneous (local) players.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub players: Option<u8>,
|
||||
}
|
||||
|
||||
impl GameMeta {
|
||||
/// The one field an installed-store scanner can assert about its own titles: they run on this
|
||||
/// host, i.e. on a PC. Everything else stays absent (the launchers' local files don't carry it).
|
||||
pub(crate) fn pc() -> Self {
|
||||
GameMeta {
|
||||
platform: Some("PC".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One title in the unified library, regardless of which store it came from.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct GameEntry {
|
||||
@@ -113,6 +164,9 @@ pub struct GameEntry {
|
||||
#[serde(skip)]
|
||||
#[schema(ignore)]
|
||||
pub detect: DetectSpec,
|
||||
/// Descriptive metadata, flattened — see [`GameMeta`].
|
||||
#[serde(flatten)]
|
||||
pub meta: GameMeta,
|
||||
}
|
||||
|
||||
/// A store that contributes titles to the library. The trait is the extension point for future
|
||||
|
||||
@@ -37,6 +37,9 @@ pub struct CustomEntry {
|
||||
/// the host would otherwise lose the game the moment the shim returns.
|
||||
#[serde(default, skip_serializing_if = "DetectHint::is_empty")]
|
||||
pub detect: DetectHint,
|
||||
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
|
||||
#[serde(flatten)]
|
||||
pub meta: GameMeta,
|
||||
}
|
||||
|
||||
/// Request body to create or replace a custom entry (no `id` — the host owns it).
|
||||
@@ -53,6 +56,10 @@ pub struct CustomInput {
|
||||
/// How to recognize this title's process — see [`CustomEntry::detect`].
|
||||
#[serde(default)]
|
||||
pub detect: DetectHint,
|
||||
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced
|
||||
/// wholesale on update, like `art`: an edit must round-trip every field it wants kept.
|
||||
#[serde(flatten)]
|
||||
pub meta: GameMeta,
|
||||
}
|
||||
|
||||
/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the
|
||||
@@ -74,6 +81,9 @@ pub struct ProviderEntryInput {
|
||||
/// through the provider's own client still end its session when the player quits.
|
||||
#[serde(default)]
|
||||
pub detect: DetectHint,
|
||||
/// Descriptive metadata (platform, description, …), flattened — see [`GameMeta`].
|
||||
#[serde(flatten)]
|
||||
pub meta: GameMeta,
|
||||
}
|
||||
|
||||
impl From<CustomEntry> for GameEntry {
|
||||
@@ -98,6 +108,7 @@ impl From<CustomEntry> for GameEntry {
|
||||
launch: c.launch,
|
||||
provider: c.provider,
|
||||
detect,
|
||||
meta: c.meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +199,7 @@ pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
|
||||
provider: None,
|
||||
external_id: None,
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
};
|
||||
entries.push(entry.clone());
|
||||
save_custom(&entries)?;
|
||||
@@ -210,6 +222,7 @@ pub fn update_custom(id: &str, input: CustomInput) -> Result<MutateOutcome<Custo
|
||||
slot.launch = input.launch;
|
||||
slot.prep = input.prep;
|
||||
slot.detect = input.detect;
|
||||
slot.meta = input.meta;
|
||||
let updated = slot.clone();
|
||||
save_custom(&entries)?;
|
||||
emit_changed("manual");
|
||||
@@ -305,6 +318,7 @@ fn reconcile_entries(
|
||||
provider: Some(provider.to_string()),
|
||||
external_id: Some(input.external_id),
|
||||
detect: input.detect,
|
||||
meta: input.meta,
|
||||
});
|
||||
}
|
||||
// `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile).
|
||||
@@ -383,6 +397,7 @@ mod tests {
|
||||
provider: None,
|
||||
external_id: None,
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +409,7 @@ mod tests {
|
||||
launch: None,
|
||||
prep: Vec::new(),
|
||||
detect: DetectHint::default(),
|
||||
meta: GameMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +423,43 @@ mod tests {
|
||||
let mut e = manual("def456", "Synced");
|
||||
e.provider = Some("romm".into());
|
||||
e.external_id = Some("rom-1".into());
|
||||
e.meta.platform = Some("PS2".into());
|
||||
let g: GameEntry = e.into();
|
||||
assert_eq!(g.provider.as_deref(), Some("romm"));
|
||||
assert_eq!(g.meta.platform.as_deref(), Some("PS2"));
|
||||
}
|
||||
|
||||
/// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting —
|
||||
/// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a
|
||||
/// pre-metadata `library.json` / payload still parses (all-optional).
|
||||
#[test]
|
||||
fn meta_is_flat_and_optional_on_the_wire() {
|
||||
let mut e = manual("abc123", "Shadow of the Colossus");
|
||||
e.meta = GameMeta {
|
||||
platform: Some("PS2".into()),
|
||||
release_year: Some(2005),
|
||||
genres: vec!["Adventure".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let v = serde_json::to_value(&e).unwrap();
|
||||
assert_eq!(v["platform"], "PS2");
|
||||
assert_eq!(v["release_year"], 2005);
|
||||
assert_eq!(v["genres"][0], "Adventure");
|
||||
assert!(v.get("meta").is_none(), "flattened, not nested");
|
||||
assert!(v.get("description").is_none(), "absent fields are omitted");
|
||||
assert!(v.get("tags").is_none(), "empty lists are omitted");
|
||||
|
||||
// A pre-metadata entry (what an existing library.json holds) still deserializes.
|
||||
let old = r#"{"id":"abc","title":"Old"}"#;
|
||||
let e: CustomEntry = serde_json::from_str(old).unwrap();
|
||||
assert!(e.meta.platform.is_none());
|
||||
|
||||
// And a provider payload can carry the fields flat, next to `title` (RFC §8 shape).
|
||||
let payload = r#"{"external_id":"rom-1","title":"OoT","platform":"N64",
|
||||
"region":"NTSC-U","players":1,"tags":["kids"]}"#;
|
||||
let p: ProviderEntryInput = serde_json::from_str(payload).unwrap();
|
||||
assert_eq!(p.meta.platform.as_deref(), Some("N64"));
|
||||
assert_eq!(p.meta.players, Some(1));
|
||||
}
|
||||
|
||||
/// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow,
|
||||
|
||||
@@ -100,6 +100,7 @@ fn epic_entry(
|
||||
};
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("epic:{app_name}"),
|
||||
store: "epic".into(),
|
||||
title,
|
||||
|
||||
@@ -57,6 +57,7 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
let detect = DetectSpec::exe(&exe).with_dir(&path);
|
||||
out.push(GameEntry {
|
||||
provider: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "gog".into(),
|
||||
title,
|
||||
|
||||
@@ -109,6 +109,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
};
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("heroic:{runner}:{app_name}"),
|
||||
store: "heroic".into(),
|
||||
title,
|
||||
|
||||
@@ -84,6 +84,7 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
for (id, slug, name, directory) in rows.flatten() {
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("lutris:{id}"),
|
||||
store: "lutris".into(),
|
||||
title: name,
|
||||
|
||||
@@ -29,6 +29,7 @@ impl LibraryProvider for SteamProvider {
|
||||
.filter(|app| !is_steam_tool(app.appid, &app.name))
|
||||
.map(|app| GameEntry {
|
||||
provider: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", app.appid),
|
||||
store: "steam".into(),
|
||||
art: steam_art(app.appid),
|
||||
@@ -382,6 +383,7 @@ fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
|
||||
}
|
||||
Some(GameEntry {
|
||||
provider: None,
|
||||
meta: GameMeta::pc(),
|
||||
id: format!("steam:{}", sc.appid),
|
||||
store: "steam".into(),
|
||||
title: sc.name,
|
||||
|
||||
@@ -70,6 +70,7 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
let art = cached_art(&id).unwrap_or_default();
|
||||
games.push(GameEntry {
|
||||
provider: None,
|
||||
meta: GameMeta::pc(),
|
||||
id,
|
||||
store: "xbox".into(),
|
||||
title,
|
||||
|
||||
@@ -333,6 +333,11 @@ fn real_main() -> Result<()> {
|
||||
// names the inherited socketpair end.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
|
||||
// Hidden: the splash client every bare gamescope spawn backgrounds beside its nested app,
|
||||
// so a fresh headless gamescope composites (and its PipeWire node delivers frames) from
|
||||
// the first second — never run by hand; it needs a gamescope session's DISPLAY.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("gamescope-splash") => vdisplay::gamescope_splash_client(),
|
||||
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
||||
// on the GPU and compare against a BT.709 limited-range reference. Validates the Tier 2A
|
||||
// `PUNKTFUNK_NV12` convert is colour-correct. Prints PASS/FAIL + max Y/U/V error.
|
||||
|
||||
@@ -8,6 +8,8 @@ use axum::http::header;
|
||||
pub(crate) struct LibraryQuery {
|
||||
/// Only entries owned by this external provider (RFC §8).
|
||||
provider: Option<String>,
|
||||
/// Only entries on this platform (case-insensitive).
|
||||
platform: Option<String>,
|
||||
}
|
||||
|
||||
/// List the game library
|
||||
@@ -15,7 +17,8 @@ pub(crate) struct LibraryQuery {
|
||||
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
||||
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
||||
/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||
/// entries a given external provider owns.
|
||||
/// entries a given external provider owns; `?platform=` to one platform (case-insensitive —
|
||||
/// installed-store titles are `PC`, custom/provider entries carry whatever was authored).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/library",
|
||||
@@ -23,6 +26,7 @@ pub(crate) struct LibraryQuery {
|
||||
operation_id = "getLibrary",
|
||||
params(
|
||||
("provider" = Option<String>, Query, description = "Only entries owned by this external provider"),
|
||||
("platform" = Option<String>, Query, description = "Only entries on this platform (case-insensitive, e.g. `PS2`)"),
|
||||
),
|
||||
responses(
|
||||
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
|
||||
@@ -36,6 +40,14 @@ pub(crate) async fn get_library(
|
||||
if let Some(provider) = q.provider.filter(|p| !p.is_empty()) {
|
||||
games.retain(|g| g.provider.as_deref() == Some(provider.as_str()));
|
||||
}
|
||||
if let Some(platform) = q.platform.filter(|p| !p.is_empty()) {
|
||||
games.retain(|g| {
|
||||
g.meta
|
||||
.platform
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.eq_ignore_ascii_case(&platform))
|
||||
});
|
||||
}
|
||||
// Rewrite provider entries' local-file art into host art-proxy URLs so a client fetches covers
|
||||
// from the host (a provider like Playnite stores on-host paths; the payload stays tiny at any
|
||||
// library size, and the client never sees an unreachable `C:\…`).
|
||||
|
||||
@@ -350,6 +350,8 @@ pub(crate) async fn serve(
|
||||
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
|
||||
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
|
||||
crate::vdisplay::restore_takeover_on_startup();
|
||||
// …and the other end of that: give the box its session back when WE are the ones going away.
|
||||
install_shutdown_restore();
|
||||
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
||||
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
||||
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
||||
@@ -486,6 +488,58 @@ pub(crate) async fn serve(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a shutdown waits for the box's session to be handed back before exiting anyway. The
|
||||
/// restore is a couple of `systemctl` calls (or one `pkexec` helper run); this only bounds a
|
||||
/// genuine wedge, well inside systemd's 90 s `TimeoutStopSec`.
|
||||
const SHUTDOWN_RESTORE_GRACE: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Hand the box's own session back on the way out. Until this existed the host had NO signal
|
||||
/// handling at all: `SIGTERM` killed it outright, which is fine for a host that owns nothing — but
|
||||
/// a managed gamescope takeover owns the box's session, and on a mask-fragile display manager
|
||||
/// (Nobara's plasmalogin) it has STOPPED that display manager for the length of the stream. Killed
|
||||
/// there, the host leaves a box with no graphical session and nothing left to restart it: the
|
||||
/// crash-restore state lives in `$XDG_RUNTIME_DIR`, which logind removes along with the user
|
||||
/// manager, so even the next host start can't heal it. `systemctl --user restart punktfunk-host`
|
||||
/// mid-stream — or a package update doing it for you — was enough.
|
||||
///
|
||||
/// So: catch `SIGTERM`/`SIGINT`, restore, then exit. Restoring runs on a blocking thread (it shells
|
||||
/// out) under [`SHUTDOWN_RESTORE_GRACE`], and a host that took nothing over exits immediately.
|
||||
fn install_shutdown_restore() {
|
||||
#[cfg(unix)]
|
||||
tokio::spawn(async {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let (Ok(mut term), Ok(mut int)) = (
|
||||
signal(SignalKind::terminate()),
|
||||
signal(SignalKind::interrupt()),
|
||||
) else {
|
||||
tracing::warn!(
|
||||
"could not install shutdown signal handlers — a host stopped mid-takeover will \
|
||||
leave the box's own session down until it is restarted"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let sig = tokio::select! {
|
||||
_ = term.recv() => "SIGTERM",
|
||||
_ = int.recv() => "SIGINT",
|
||||
};
|
||||
tracing::info!(
|
||||
signal = sig,
|
||||
"host stopping — handing the box's session back"
|
||||
);
|
||||
let restore = tokio::task::spawn_blocking(crate::vdisplay::restore_takeover_now);
|
||||
if tokio::time::timeout(SHUTDOWN_RESTORE_GRACE, restore)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
secs = SHUTDOWN_RESTORE_GRACE.as_secs(),
|
||||
"the session restore did not finish in time — exiting anyway"
|
||||
);
|
||||
}
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/// The accept loop is sequential, so the control phase must be bounded — a client that
|
||||
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
|
||||
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
@@ -335,6 +335,11 @@ struct FrameMsg {
|
||||
/// nor a stats capture is armed). The send thread accumulates them for the web-console sample:
|
||||
/// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture`
|
||||
/// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage).
|
||||
/// SYNCHRONOUS backends (PyroWave: the whole GPU encode + fence wait runs inside `submit`)
|
||||
/// carry their real encode time in `submit_us`, and the "encode" stage reads ~0 by
|
||||
/// construction — read the pair together (the 2026-07 field triage read "encode 0.00" as an
|
||||
/// instrumentation hole; it's the stage split's shape). The client-facing 0xCF `encode_us`
|
||||
/// is unaffected: its anchor is stamped before `submit`, so it spans both.
|
||||
cap_us: u32,
|
||||
submit_us: u32,
|
||||
wait_us: u32,
|
||||
@@ -1118,9 +1123,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
||||
}
|
||||
if streamed_wire {
|
||||
// Client capability only — whether AUs actually stream per-slice depends on the encoder
|
||||
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
|
||||
// exist yet at this point. The old wording ("chunked encoder output will stream
|
||||
// per-slice") sent a 2026-07 field triage chasing a streaming path AMF doesn't have.
|
||||
tracing::info!(
|
||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
|
||||
will stream per-slice"
|
||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — used if this session's \
|
||||
encoder supports chunked output"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
|
||||
@@ -542,8 +542,14 @@ mod tests {
|
||||
// it from there. (A wrapper script that `exec`s something outside the directory would leave no
|
||||
// trace of the directory in either the image path or the command line — worth knowing, and the
|
||||
// reason a copied binary is the honest fixture here.)
|
||||
//
|
||||
// It has to keep the name `sleep`. Modern coreutils (uutils on Ubuntu 25.10+, busybox
|
||||
// elsewhere) is a MULTI-CALL binary that dispatches on `argv[0]`: copied to any other name it
|
||||
// prints "unknown program" and exits instantly, leaving nothing in `/proc` to find — which
|
||||
// looks exactly like the matcher being broken. That cost a CI red, green on a distro with a
|
||||
// standalone `sleep` and failing on one without.
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let game = td.path().join("game");
|
||||
let game = td.path().join("sleep");
|
||||
std::fs::copy("/bin/sleep", &game).expect("copy a stand-in game binary");
|
||||
let s = Scanner::system();
|
||||
let before = s.now_stamp().expect("real /proc/uptime is readable");
|
||||
|
||||
@@ -45,7 +45,9 @@ pub struct StatsSample {
|
||||
pub fps: f32,
|
||||
/// Re-encoded holds/s (source-starvation indicator).
|
||||
pub repeat_fps: f32,
|
||||
/// Transmit goodput (Mb/s).
|
||||
/// Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes
|
||||
/// plus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned
|
||||
/// mode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops.
|
||||
pub mbps: f32,
|
||||
/// Configured target bitrate.
|
||||
pub bitrate_kbps: u32,
|
||||
|
||||
@@ -403,7 +403,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
|
||||
// instead of stacking a second (possibly all-profiles) rule behind the new one.
|
||||
let fw_profile =
|
||||
crate::service::firewall_profile_arg(crate::service::allow_public_network(args));
|
||||
crate::service::firewall_profile_arg(crate::service::allow_public_network(args)?);
|
||||
run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
|
||||
@@ -718,8 +718,9 @@ fn install(args: &[String]) -> Result<()> {
|
||||
// Firewall scope: Domain + Private by default; `--allow-public-network` opts into Public too.
|
||||
// Persist the choice (so the startup warning respects an opt-in) and re-scope idempotently —
|
||||
// remove any prior rules first so an upgrade tightens the scope instead of leaving a stale
|
||||
// all-profiles rule behind the new one.
|
||||
let allow_public = allow_public_network(args);
|
||||
// all-profiles rule behind the new one. With the flag absent (every upgrade) this resolves to
|
||||
// the previously recorded choice, so the rewrite below is a no-op rather than a silent reset.
|
||||
let allow_public = allow_public_network(args)?;
|
||||
set_fw_public_marker(allow_public);
|
||||
remove_firewall_rules();
|
||||
add_firewall_rules(allow_public);
|
||||
@@ -889,10 +890,37 @@ pub(crate) fn firewall_profile_arg(allow_public: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--allow-public-network` install opt-in (the installer's "Allow connections on Public
|
||||
/// networks" task forwards it). Absent = the secure default (Domain + Private only).
|
||||
pub(crate) fn allow_public_network(args: &[String]) -> bool {
|
||||
args.iter().any(|a| a == "--allow-public-network")
|
||||
/// Resolve the Public-network firewall scope for this install/upgrade (the installer's "Allow
|
||||
/// connections on Public networks" task forwards the flag).
|
||||
///
|
||||
/// Tri-state, mirroring `--gamestream=on|off`:
|
||||
/// * `--allow-public-network` or `=on` -> `true` — explicit opt-in. The bare form is kept for
|
||||
/// back-compat with existing scripts and docs that pass it that way.
|
||||
/// * `--allow-public-network=off` -> `false` — explicit opt-out.
|
||||
/// * absent -> whatever the previous install recorded.
|
||||
///
|
||||
/// The absent case is what makes an UPGRADE non-destructive. A silent upgrade (`winget upgrade`)
|
||||
/// has no wizard to carry the old checkbox forward, so the installer omits the flag entirely on an
|
||||
/// upgrade and the recorded choice stands. Re-applying the task default instead would quietly
|
||||
/// re-scope the firewall on every upgrade, undoing an opt-in the user made once. On a first-ever
|
||||
/// install there is no marker, so absence resolves to the secure default (Domain + Private only).
|
||||
///
|
||||
/// Strict on the value: a typo'd `=of` must NOT fall through to the marker, because the marker may
|
||||
/// say `true` — i.e. a mistyped opt-OUT would silently leave Public open.
|
||||
pub(crate) fn allow_public_network(args: &[String]) -> Result<bool> {
|
||||
for a in args {
|
||||
if let Some(v) = a.strip_prefix("--allow-public-network") {
|
||||
return match v {
|
||||
"" | "=on" => Ok(true),
|
||||
"=off" => Ok(false),
|
||||
_ => bail!(
|
||||
"--allow-public-network must be 'on' or 'off' (got '{}')",
|
||||
v.trim_start_matches('=')
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(fw_public_marker().exists())
|
||||
}
|
||||
|
||||
/// Inbound firewall rules for the streaming + mgmt ports (best-effort; logs but never fails the
|
||||
|
||||
@@ -69,6 +69,14 @@ depends on the display manager driving the autologin:
|
||||
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
|
||||
[Configuration](/docs/configuration)) is fired as the fallback.
|
||||
|
||||
**Lingering is required here**, and the host turns it on for you the first time it takes the box
|
||||
over. Stopping the display manager ends your last login session, and without
|
||||
`loginctl enable-linger` logind stops your `systemd --user` manager about ten seconds later —
|
||||
taking the host with it, mid-stream, with the display manager down and nothing left to bring it
|
||||
back. If lingering can't be enabled the host refuses the takeover and degrades to attach instead
|
||||
(above) rather than risk that. Run `sudo loginctl enable-linger "$USER"` once, as the setup guides
|
||||
ask; `loginctl disable-linger "$USER"` reverts it.
|
||||
|
||||
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
|
||||
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
|
||||
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||
|
||||
@@ -179,6 +179,23 @@ shutdown — and only forces the issue after ten seconds of being ignored.
|
||||
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
|
||||
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
|
||||
|
||||
### On a gamescope session, the display has the final say
|
||||
|
||||
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam Deck
|
||||
or a Bazzite couch box — the game runs *inside* the streamed display. So it lives exactly as long as
|
||||
that display does, and **Keep alive decides that, not the setting above**:
|
||||
|
||||
| you disconnect by | what happens to the game |
|
||||
|---|---|
|
||||
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is deliberately skipped for a real stop — and the game goes with it, even on *Leave it running* |
|
||||
| dropping out (network, sleep) | the display lingers for your keep-alive window, then tears down; the game ends with it |
|
||||
| dropping out, keep-alive **Forever** | the display is pinned, so the game genuinely survives — and *Always close it* still ends it when the reconnect window closes |
|
||||
|
||||
So on a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
|
||||
window long enough to come back in), not just this setting. On a desktop session — KWin, GNOME, Sway —
|
||||
the game is an ordinary process next to your desktop and none of this applies; the setting above is
|
||||
the whole story.
|
||||
|
||||
### Automation
|
||||
|
||||
The host publishes `game.running` and `game.exited` events (the latter says whether the player quit
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
Update whenever it suits you — the app and the machine you stream from can be updated one at a time, old and new work together in either combination, and everything you've already paired stays paired.
|
||||
|
||||
The headline: **Punktfunk now knows when your game ends.** Quit a game and the stream ends with it — on Windows and Linux, in the Punktfunk app and in Moonlight — instead of dropping you onto someone else's desktop. Turn it around if you want, and ending the stream can close the game too. The console and the tray show what is running and who is about to close it. Underneath that sits the largest sweep of the video encoder and screen capture Punktfunk has had: machines that could never stream at all now do, an idle stream on an AMD or Intel PC has stopped sending megabytes of padding every second, 4K at 120 fps holds its frame rate, HDR from an Intel PC is finally the right brightness, and a stack of pointer, controller and reconnect bugs are gone. Windows also gets its first real update path — install and upgrade the host with winget — and there is now a client for 64-bit ARM Linux machines, which you can pair over SSH without installing a desktop on them.
|
||||
|
||||
## New: the stream ends when the game does
|
||||
|
||||
Until now this only worked in one narrow case — a Steam game on a Linux machine running Steam's own Game Mode. Everywhere else the host launched your game and forgot about it, so finishing a game left you looking at a desktop until you disconnected by hand. It now works on **Windows and Linux**, for games launched through **Steam, Epic, GOG, Xbox, Lutris, Heroic** or added by hand, and in **Moonlight** as well as the Punktfunk app.
|
||||
|
||||
Punktfunk waits out the usual hand-off, so a launcher that starts your game and exits is no longer mistaken for the game itself — `steam://`, `epic://` and Playnite launches don't end the session the moment they begin. Where nothing can identify what was launched, the behaviour stays off and the host says so once, rather than guessing.
|
||||
|
||||
## New: and optionally, the game ends when the stream does
|
||||
|
||||
Off by default, because closing someone's game is destructive. Switch it on and disconnecting closes the game you launched — useful for a shared machine or a games box you stream to from the sofa.
|
||||
|
||||
It is careful about it:
|
||||
|
||||
- **A dropped connection is not a decision.** Wi-Fi cutting out gives you a **five-minute** window to come back; reconnecting reclaims your game and cancels the countdown.
|
||||
- **Only ever the game this session started.** A copy you already had open is never touched.
|
||||
- **It asks before it insists** — the game is asked to close normally, and only forced if it refuses.
|
||||
- **A display you asked Punktfunk to keep open stays open**, regardless of this setting.
|
||||
|
||||
On macOS, which has no game-launching path at all, both controls are shown but disabled — "does nothing here" is worth knowing.
|
||||
|
||||
## New: see what's running, and end it from anywhere
|
||||
|
||||
- **The console dashboard has a running-game card** with box art, above the session card. "End now" ends a live game by stopping its session, or ends one already counting down after a disconnect.
|
||||
- **The tray shows the running game too**, including the countdown for a game whose client walked away — visible at the machine without opening the console.
|
||||
- **Automation gets an event when a game starts and when it stops**, filterable like every other event, so scripts can react to a game rather than to a stream. If you have been polling the host to find out when a game finished, you can stop.
|
||||
- **The settings for both behaviours** sit next to the display keep-alive policy in the console, which is the same question one step out.
|
||||
|
||||
## New: richer library entries
|
||||
|
||||
Every library entry can now carry **platform, description, developer, publisher, release year, genres, tags, region and player count** — the things an emulation or retro library needs and a title-and-poster shape could not hold. The console's add/edit form gains a Details section, poster tiles show a platform badge and the year, and the library can be filtered by platform. Existing libraries, provider plugins and clients keep working untouched; every field is optional.
|
||||
|
||||
**Plugin authors:** a provider entry can now also say how to recognise its games — install directory, executable, or process name — so titles launched through a provider's own client get the same start-and-end tracking as a Steam or Epic title.
|
||||
|
||||
## New: install and update the Windows host with winget
|
||||
|
||||
Windows had **no update path at all** — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now it is two commands.
|
||||
|
||||
Punktfunk lives in **its own package source**, not the public winget catalogue, so add that first. Once per machine, from an **Administrator** terminal:
|
||||
|
||||
```
|
||||
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
|
||||
```
|
||||
|
||||
Then install, and from then on upgrade:
|
||||
|
||||
```
|
||||
winget install unom.PunktfunkHost
|
||||
winget upgrade unom.PunktfunkHost
|
||||
```
|
||||
|
||||
**If you skip the first command, `winget install unom.PunktfunkHost` will tell you no package was found** — winget only searches the sources it knows about, and Punktfunk is not in the public one. The source knows about every release, so you can also pin an older version with `--version` or upgrade from one.
|
||||
|
||||
A silent install shows you the same disclosures the wizard does — including where the bundled virtual audio device comes from — and takes the same defaults the wizard offers, so installing without a screen doesn't quietly get you a different machine.
|
||||
|
||||
## New: a client for 64-bit ARM Linux machines
|
||||
|
||||
Punktfunk now has a client build for **64-bit ARM Linux** — the class of small board and mini-PC people put behind a TV — published to the apt repository alongside the Intel/AMD one. It is the full client, on-screen overlay included; your box picks it up automatically with no extra setup. The machine you stream *from* is still Intel/AMD only, because the hardware video encoders it uses are.
|
||||
|
||||
## New: pair a machine that has no desktop, over SSH
|
||||
|
||||
A box that only has a terminal can now enrol itself:
|
||||
|
||||
```
|
||||
punktfunk-session --pair <PIN> --connect <host>
|
||||
```
|
||||
|
||||
It runs the same pairing handshake as the app and prints the same confirmation. Until now the pairing screen only existed inside the desktop app, so setting up a kiosk or a headless media box meant installing a whole desktop on it first — or copying credentials across by hand.
|
||||
|
||||
## New: pick which graphics card drives the screen on a display-only box
|
||||
|
||||
On a machine with two graphics cards and no desktop running, Punktfunk's own display client used to pick a card and hope — regularly choosing the one already in use and failing to start, while the card with the actual monitor attached sat idle. You can now pin the card explicitly. And if starting the display still fails, the error names the card it tried and lists what to check, instead of a one-line internal failure.
|
||||
|
||||
## Fixed: computers that could never stream now can
|
||||
|
||||
Six separate reasons a machine would connect and then die on its first frame — or refuse to stream at all — each with its own kind of host behind it:
|
||||
|
||||
- **Laptops with both Intel and NVIDIA graphics.** Punktfunk assumed the first graphics device it found was the NVIDIA one. On these laptops it is the Intel chip, so setup failed with a message about graphics configuration on a machine whose NVIDIA drivers were perfectly healthy. It now looks for the NVIDIA card by identity, and every message on this path names the card it used.
|
||||
- **Machines whose driver refuses the fast frame handoff now fall back by themselves.** Previously that host lost every session on the first frame, and every reconnect repeated it — while the very same machine streamed fine with the fast path switched off by hand. Punktfunk now notices after three refusals and switches that machine to the slower-but-working path on its own, with a log line saying so. One bad session, then a working computer.
|
||||
- **Older Intel graphics.** The shared frame was handed to the driver declared as **empty** — most drivers work the real size out, older Intel ones answer "out of memory" on every frame instead. It now declares the true size — it is the first thing the driver reads, and it was wrong for every host we have ever handed a frame to; whether it is the whole story on the machine that reported this is not yet confirmed.
|
||||
- **Linux desktops running gamescope** whose graphics drivers are missing the newer built-in video encoder. Punktfunk committed the capture to a format only that encoder can take, and there is deliberately no going back once it has — so the session died at its first frame. It now checks the encoder is really there *before* committing.
|
||||
- **Wayland desktops that hand over 24-bit frames.** A common frame layout had no handling at all in one of the Linux encoders, so those sessions connected and immediately failed. They are now supported outright rather than refused.
|
||||
- **PCs with no graphics card.** A host encoding in software advertised HEVC and AV1 to Moonlight — which its software encoder cannot produce — so Moonlight picked one and the session died at start-up. It now offers H.264 only, honestly. In the same vein, a resolution beyond what the software encoder supports is now refused at connect, with the reason, instead of connecting successfully and never delivering a single frame.
|
||||
|
||||
## Fixed: an idle stream was sending megabytes of nothing
|
||||
|
||||
On an **AMD or Intel PC streaming from Linux** — a Steam Deck included — a calm picture did not use less bandwidth. It used all of it. Once the stream settled, the driver padded every single frame back up to the full target bitrate with zeros, forever: measured on an AMD laptop chip, 300 frames of a calm desktop at 10 Mbps came to **5.63 MB, of which 98.5% was padding**. The same 300 frames now come to **83 KB with no padding at all**; on AV1 the padding went from 99.6% to zero.
|
||||
|
||||
That padding was also convincing the automatic quality control that the connection was comfortably full, so it never eased off. If you stream over anything metered, capped, or shared, this is the single biggest change in the release.
|
||||
|
||||
## Fixed: 4K at 120 fps holds its frame rate
|
||||
|
||||
A 4K120 stream on a fast local network climbed its way into a hole: the quality control kept raising the bitrate, the encoder quietly ran at a lower rate than it reported, and the stream kept rebuilding itself to chase a number it was never going to hit — landing at around 107 of 120 frames per second with visible stutter.
|
||||
|
||||
- **What the encoder actually delivers is now what gets reported** — to the pacing, to the web console, and back to your device. The bitrate control no longer climbs away from a phantom number.
|
||||
- **Punktfunk learns the ceiling of your hardware** and stops re-discovering it the hard way — each discovery used to cost a full encoder rebuild mid-stream.
|
||||
- **At 4K120 both encoder engines on the graphics card are used**, instead of the second one sitting idle in exactly the mode it exists for.
|
||||
- **Your device notices when the host is struggling to encode**, not just when the network is, and eases off — on a fast LAN nothing else would ever have brought the rate back down.
|
||||
|
||||
## Improved: a stream that stumbles once no longer stays degraded
|
||||
|
||||
When a stream hits sustained trouble, Punktfunk trades a little latency for stability — buffering more, and switching the encoder into a mode that keeps up under load. That trade used to be **permanent**: one rough patch, however brief, and the session ran at higher latency until you reconnected.
|
||||
|
||||
It now winds back. After a sustained stretch of clean, on-time frames the stream steps back down — first to the low-latency encoder mode, then to minimum buffering — and it retries progressively less often, so a machine that genuinely needs the trade settles into keeping it rather than flapping.
|
||||
|
||||
## Fixed: garbled picture on 10-bit PyroWave streams
|
||||
|
||||
Streaming in **PyroWave at 10-bit** produced complete corruption on the client — scrambled tiles and saturated green, at any bitrate or refresh rate, with no error anywhere and clean network counters. Reported on an AMD host into an AMD laptop client; milder versions of the same fault were possible on NVIDIA clients. The client was reading the decoded picture through the wrong description of its own buffers, which most drivers execute anyway rather than refusing. 8-bit sessions were never affected.
|
||||
|
||||
## Fixed: HDR
|
||||
|
||||
- **From an Intel PC, HDR is the right brightness.** The video was tagged with a peak brightness **ten thousand times too low** — 0.1 nits where 1000 was meant. TVs and monitors tone-map from exactly that number, so the picture arrived wrong at the far end. Confirmed fixed by reading the tags back out of a real Intel-encoded stream.
|
||||
- **Moonlight no longer gets an HDR label on an SDR picture.** If HDR capture failed to start — a timed-out negotiation, a monitor that left HDR mode at the wrong moment — the host went on telling the client the stream was HDR while capturing and encoding ordinary colour. The client then renders it as HDR: washed out, wrong colours, no error, and every reconnect repeated it until the host was restarted.
|
||||
- **A graphics card that cannot encode 10-bit no longer rebuilds the encoder on every single frame** when HDR content is streamed to it — a full teardown and rebuild per frame.
|
||||
- **When switching the streamed display into HDR fails, the session survives.** The capture correctly falls back to ordinary colour, but the encoder kept insisting on HDR — one of the three Windows encoders then failed every frame forever, and the session ended after burning through its recovery budget. The encoder now follows the pixels it is actually handed, and labels the stream honestly.
|
||||
- **On a Windows machine with its own dedicated streaming display**, a mismatch between what the display was composing and what the encoder expected could leave the session in a permanent three-second reconnect loop.
|
||||
|
||||
## Fixed: the mouse pointer
|
||||
|
||||
- **On iPad, iPhone and Mac, the text cursor stopped disappearing.** A pointer whose new picture had not arrived yet was hidden outright rather than left as it was — routine, because the host announces a new pointer shape the moment it sends it. Reported on glass as "the I-beam never appears over text fields, every other cursor is fine".
|
||||
- **On Windows, a pointer three times too large is fixed.** Windows quietly re-draws its cursors at a new size when display scaling changes, behind an unchanged internal handle — so Punktfunk latched whatever size the pointer had a second after the streamed display appeared, which is exactly when the scale is still settling, and streamed that size for the whole session.
|
||||
- **Sessions that streamed with no pointer at all now have one.** Some encoders cannot draw the pointer into the video; the host now works that out *before* capture starts and asks the desktop to draw it instead, rather than warning and streaming a pointerless picture.
|
||||
- **A stray space in a configuration file could make the pointer vanish.** A setting written as `=0 ` — with a trailing space, as an editor or shell script leaves behind — was read as the *opposite* of what it said, switching on a mode that cannot draw the pointer at all.
|
||||
- **In Steam's Game Mode, the pointer lands where it should**, no longer drawn at a fraction of its real position when the game runs at a different resolution than the stream — and it no longer disappears for the rest of a game session when the game opens its own window.
|
||||
- **AV1 at 1080p is now genuinely 1080p.** It was shipping eight rows of duplicated pixels along the bottom edge; playback now reports the true size and the padding is gone rather than cropped back off.
|
||||
- **A visible pointer no longer costs you frame rate.** On a Linux machine with an NVIDIA card running Steam's Game Mode — where the pointer is drawn into every frame, unlike a normal game — a 120 fps session was capped at around 80, with the graphics card barely working. Drawing the pointer was making the encoder wait for the game's own work to finish, on every frame. It doesn't any more.
|
||||
|
||||
## Fixed: your controller type setting is honoured again
|
||||
|
||||
Choosing "emulate my controller as a DualShock 4" reached the host and then stopped working the instant the controller actually connected — every pad re-declared itself as what it physically was, so the setting appeared to do nothing. This affected the Punktfunk apps on Apple platforms, Windows, Linux and macOS (Android already did this correctly), and it has been broken since multi-controller support shipped in 0.19.x. Your explicit choice now applies to every controller slot; **Automatic** still detects each pad individually so a mixed session stays honest. Your controller keeps its own local features either way — a DualSense emulated as a DualShock 4 keeps its lightbar.
|
||||
|
||||
**And Windows Device Manager now names each emulated pad correctly** — all four types previously showed up as "punktfunk Virtual DualSense", which reads exactly like the setting being ignored.
|
||||
|
||||
## Fixed: Windows machines with a dedicated streaming display
|
||||
|
||||
- **The dedicated display stays dedicated.** Punktfunk switched the physical monitors off, verified it, and never looked again — and on laptops with two graphics chips the internal panel came back moments later on its own. The host still believed it had the screen to itself, so windows and the mouse could land off-stream and the lock screen could appear on the physical panel. It is now re-checked every couple of seconds while the session runs, and put back if something re-lights a display.
|
||||
- **Choosing a graphics card in the console no longer wedges the host.** If your host configuration file pinned an encoder belonging to a different make of graphics card than the one you picked, every session failed at the same point and the client reconnected into the identical wall roughly every ten seconds, with no visible reason. The card you pick now wins, and the console shows the pin with a warning when the two disagree.
|
||||
|
||||
## Fixed: streaming from a Linux desktop, session after session
|
||||
|
||||
Four faults that all shared a shape — the first session worked, and something never came back afterwards:
|
||||
|
||||
- **Each Moonlight reconnect leaked a screen-recording session** with your desktop, until the compositor started refusing new ones — the very "another app is already recording" clash the pooling was there to prevent.
|
||||
- **One compositor restart could wedge Moonlight video permanently**, ten seconds per reconnect attempt, until the host was restarted: a dead capture was put back in the pool and handed out again forever.
|
||||
- **One failed negotiation switched the whole machine off the fast capture path** — every later session, on every encoder, until restart. It is now scoped to the one thing that actually failed.
|
||||
- **A stalled moment cost you the wrong frames.** Under load the capture kept the *oldest* eight frames and discarded everything newer, so the picture caught up through stale frames instead of jumping to the current one. It now always hands over the freshest frame. A reused capture also no longer opens the next session showing the last frame of the previous one.
|
||||
|
||||
## Fixed: Moonlight
|
||||
|
||||
- **Moonlight returns to its app list when the host ends the session.** Whatever ended it — your game exiting, someone pressing stop in the console, another client taking over — the client simply froze on its last frame and eventually reported the session as a connection failure, because the host tore everything down without ever saying it had. It now says so, and Moonlight lands you back in the app list exactly as quitting does.
|
||||
- **A wedged encoder no longer ends the stream.** If the graphics card's encoder locked up mid-stream, Moonlight clients got a full disconnect and had to reconnect. The Punktfunk app has recovered in place from this for a while; the Moonlight path now does the same, with the same bounded retries and backoff.
|
||||
|
||||
## Fixed: launching a game into a fresh Steam session streams from the first second
|
||||
|
||||
On a Linux machine where Punktfunk starts a **dedicated Steam session** for you — rather than attaching to one already running — launching a game showed you nothing at all, and the attempt was retried from scratch and eventually given up on. The cause was a chicken-and-egg: that display only produces a picture when something on it is drawing, and Steam draws nothing at all until its interface first appears, which takes far longer than the time allowed for a first frame. Sessions that reused an already-running display worked, which pointed away from the real cause for a long time.
|
||||
|
||||
Punktfunk now puts a quiet loading screen up from the first second, so there is always something to stream while Steam boots. The dedicated session also comes up in **Big Picture** now, instead of the desktop Steam window flashing through your stream on the way to the game.
|
||||
|
||||
## Fixed: an idle computer pinned a whole CPU core
|
||||
|
||||
Punktfunk's script and plugin runner burned **100% of one CPU core doing nothing** — in exactly the state its own documentation calls inert, with no scripts and no plugins installed. One report had it at 99.9% for two hours on a small laptop, starving the desktop badly enough to blank the screen. Three seconds idle used to cost three seconds of CPU; it now costs a tenth of that, all of it start-up.
|
||||
|
||||
## Fixed: Steam Game Mode takeover on Nobara and KDE machines
|
||||
|
||||
The 0.19.2 fix for switching a Linux machine into Steam's Game Mode needed a permission that was only ever a manual, documented step — so on a normal install it always fell back to mirroring, and with a monitor attached you got the desktop's own resolution mirrored instead of a proper takeover. The permission now ships **with the packages**, so entering Game Mode mid-stream takes the screen over as intended, and everything it changed is put back when you disconnect.
|
||||
|
||||
Punktfunk also now warns in its log when the installed gamescope is too old to support properly, instead of failing in ways that look unrelated.
|
||||
|
||||
## Fixed: the Windows installer stops undoing your choices
|
||||
|
||||
- **Re-installing or upgrading keeps your settings.** Both the Moonlight-compatibility option and the "allow Punktfunk through the firewall on Public networks" option were re-applied from scratch on every run — so an upgrade turned Moonlight support back off for anyone who had switched it on, and quietly closed the Public-network firewall rule for anyone who had opened it. Both are now decided on a fresh install only.
|
||||
- **An unattended install can't stall on an invisible dialog.** The warning about another streaming host being present (Sunshine, Apollo) was a message box that appeared even in silent mode, so an install nobody was watching sat on a dialog nobody could click. It now declines and stops, which is the honest answer for a combination that warning already calls unsupported.
|
||||
- **It calls itself "Punktfunk Host"** in Add/Remove Programs, the Start Menu group and the wizard, instead of the internal name. Install locations are untouched.
|
||||
|
||||
## Fixed: the tray icon, and the Windows web console after a fresh install
|
||||
|
||||
- **The tray said "Idle" through an entire stream.** It only counted Moonlight-style sessions as streaming, and ordinary Punktfunk sessions — the default — never registered. The icon and tooltip now track any live session.
|
||||
- **"Open web console" no longer disappears from the tray menu.** It was built only if a live check of the console happened to answer within two seconds, so a console still starting up simply had no menu entry — with no discoverable way in. It is always there now, and says "(not responding)" when it genuinely is not.
|
||||
- **No more false "conflicting host" alarm.** The tray warned about Sunshine being *installed* — not running, not listening — on every poll, and on Linux flagged the icon for attention over it. The host still reports genuine conflicts where that belongs.
|
||||
- **New "Release kept display…" entry** when Punktfunk is holding a display open, and the menu entries deep-link to the pairing and display pages instead of all landing on the dashboard.
|
||||
- **A fresh Windows install left the web console down until the next reboot.** The installer started it before the files it needs existed, the launcher exited, nothing retried — and the installer still reported success. It now waits for what it needs, verifies the console really came up, and says so honestly if it did not. The Linux service had the same flaw and would give up permanently after five quick restarts.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- **Nothing changed on the wire or at the API boundary.** The streaming protocol stays at version **2**, the embeddable core library stays at C ABI **13**, and the Windows virtual-display driver protocol stays at **6** — no embedder rebuild, and 0.18/0.19/0.20 hosts and clients mix freely. The library's new `GameMeta` fields are optional and flat on the wire, so existing `library.json` files, provider plugins and clients are unaffected; `GET /library` gains a case-insensitive `?platform=` filter beside `?provider=`, and the SDK and OpenAPI spec are regenerated. New `game.running` / `game.exited` events are filterable like every other kind, and `/status` reports `games[]` including a game whose session has gone and which is waiting out its window, with `POST /game/end` to end it.
|
||||
- **Session⇄game lifetime.** `DetectSpec` (install dir / exe / process name / appid / env marker) comes from each store's scanner and from an optional provider `detect` hint; `procscan` turns it into live pids per OS; `gamelease` turns pids into a lifetime with four kinds — `nested` (gamescope owns it), `child` (host-spawned, own process group), `matched` (a launcher owns it, recognised by store signals) and `untracked`. A child exiting successfully within 5 s is a launcher handing off, and re-resolves to `matched` rather than reporting an exit — that single rule is what keeps `steam://`, `epic://` and `playnite://` from ending a session at launch. A pid is adopted only if it started after the launch, and re-verified against its start time immediately before being signalled, so a recycled pid is never touched. Provider-supplied hints never win over the host's own findings, and a blank field is absent rather than match-everything (an empty install dir would prefix-match every process on the box, and this feature ends processes). On Windows: Toolhelp + `QueryFullProcessImageNameW` + `GetProcessTimes`, `\\?\`-normalised case-insensitive path matching, Steam's per-app `Running` flag used only as a **veto** (it cannot be trusted to say a game *is* running, but it is exactly right for refusing to declare one gone), and the terminating thread binds to the input desktop before `EnumWindows`/`WM_CLOSE` — the host's own desktop is session 0 and holds none of the user's windows, so without the bind the polite pass finds nothing and every game dies unsaved. Job Objects are deferred with reasoning in the design doc. GameStream gained a session quit flag (RTSP carries no close code, so `/cancel`, a management stop and a game exit were indistinguishable from a client vanishing), and both planes now resolve a launch through one `resolve_launch`. Two on-glass runs each found a defect the fixtures could not: on Linux (.41) a `Child` lease left its waiting phase on the FIRST poll, because a live child counts as the game running — and the shim reclassification lives in that phase, so every Steam launch over the compat plane ended the session ~7 s later when `steam` handed off and exited; a bare live child now only counts once the shim window has passed, where the store gave us signals to recognise the real game by. On Windows (.173) `procscan::launch_stamp`'s wrapper was gated `#[cfg(target_os = "linux")]` and answered `None` everywhere else — and `None` doesn't fail, it turns the filter OFF, so every process under the install dir became adoptable and a deliberate stop closed a pre-existing copy of the game. The guard added there asserts the reference *exists* wherever processes can be matched, which is the shape of that failure: silent, and invisible downstream.
|
||||
- **Vulkan encode moves from CBR to VBR**, which is the whole filler story: Vulkan exposes no filler-suppression control (no equivalent of AMF's `filler_data=false` or NVENC's default-off), so the rate-control *mode* is the only lever, and CBR under the shipped 1000 ms window overflows the CPB once the initial fill drains (~30 frames at 10 Mbps/60 fps) and pads every frame to the exact rate share thereafter. `VkVideoEncodeCapabilitiesKHR::rateControlModes` was previously ignored — CBR was hardcoded with no capability check; VBR now installs with average == max plus the house ~1-frame window when advertised, CBR-only drivers keep the loose window (tightening it just starts the stuffing earlier), and drivers advertising neither (ANV per current Mesa) keep the old install with a WARN. `PUNKTFUNK_VULKAN_RC=cbr|vbr` is the escape hatch and the on-box A/B control. No pacing claim: a burst A/B on the 780M is byte-identical between 1000 ms CBR and 17 ms VBR, so this firmware ignores the window for QP decisions entirely — the payload is filler elimination. ⚠ The first attempt at this was withdrawn after measuring a "36× bandwidth regression"; that measurement's baseline row was an 8-frame artifact, and the stuffing it feared was already live in shipped code. `maxBitrate` is now read from the same caps struct and clamps open + retarget, and `applied_bitrate_bps()` reports encoder-side truth pending-first.
|
||||
- **Phases 3–8 of the pf-encode audit landed here.** Highlights not in the body: `ensure_cpu_rgb` cached its staging image on format alone while sizing it to the source (8× `VUID-vkCmdCopyBufferToImage-imageSubresource-07971` with `submit` returning `Ok` throughout); `reset()` re-armed `first_frame`, which also gated the begin-video-coding rate-control declaration (`VUID-vkCmdBeginVideoCodingKHR-pBeginInfo-08253`); AV1 at unaligned modes violated `VUID-vkCmdEncodeVideoKHR-flags-10324`/`-10325` on every frame under RGB-direct, fixed by making the sequence header, DPB setup and reference slots agree at the render size while keeping the EFC fast path; `VK_EXT_queue_family_foreign` was named as the dmabuf acquires' source family on four sites without ever being enabled (spec-invalid everywhere, tolerated by RADV) and is now enabled when advertised with a core-1.1 substitute otherwise; and a one-frame `VUID-...-08254` where a pending retarget was promoted into the session rate before recording, which correlated with the two triggers that fire together (ABR retarget + stall watchdog).
|
||||
- **NVENC split-encode vs sub-frame is now arbitrated** against `nvEncodeAPI.h`'s own doc rather than the audit's one-liner: H.264 hard-disables split (it "is not applicable", so the rejection-retry used to re-open a byte-identical session), HEVC yields sub-frame when *we* force split for 4K120 throughput — keyed on the FORCED modes only, never `!= DISABLE`, since AUTO is the resolver's fallthrough for every sub-950 Mpix session and the wider key would have disarmed chunked polling fleet-wide — and AV1 is untouched. libav-NVENC open failures are classified by typed errno rather than by an English `strerror` substring over the whole context chain.
|
||||
- **The pf-capture sweep (Phases 0–6).** Phase 0 put the crate under CI at all (`-p pf-capture --all-targets` on Windows: its `#[cfg(test)]` modules were compiled by nothing) and added `deny(unsafe_op_in_unsafe_fn)` — 119 operations across 16 functions gained SAFETY proofs, because in edition 2021 the existing `undocumented_unsafe_blocks` deny has nothing to fire on inside an `unsafe fn`, exempting the hardest FFI in the crate from its own program. Then a truth pass over comments and log strings, then the defect fixes: the portal thread parked on `future::pending()` so every dropped capturer leaked a thread, a 2-worker tokio runtime, a zbus connection *and* the compositor-side cast; `Capturer::is_alive()` now gates re-pooling; the "VAAPI" downgrade latch was really a global zero-copy kill switch fed by a hand-mirrored copy of `spawn_pipewire`'s negotiation decision, now one `negotiation_plan` resolver consumed by both; the frame channel was `sync_channel(8)` + `try_send` (drop-*newest*) against a trait documenting drop-oldest, replaced by a one-deep overwriting mailbox; four Linux buffer-geometry defects including a self-mmap that ignored `spa_data.mapoffset` and read the wrong buffer out of a pooled memfd; and on Windows the HDR pin wrote the *desired* state in place of the observed one (a permanent 3 s reconnect loop in one direction, a silently mismatched ring in the other), plus cursor rect staleness, recreate ordering, `LocalAlloc`/NT-handle leaks, and an `f32_to_f16` that swallowed the rounding carry (`1.9998779 → 1.0`) and so failed a correct shader. Phase 5 split `linux/mod.rs` 2,778 → 770 lines; Phase 6 took the suite from 6 to **38 Linux + 19 Windows tests**.
|
||||
- **Cursor blending is negotiated ahead of capture.** `EncoderCaps::blends_cursor`'s contract said the host must fall back to capturer-side compositing, and that host half was never built — `open_video` warned and streamed pointerless (confirmed on the VAAPI dmabuf and libav-NVENC CUDA paths). `cursor_blend_capable()` is the pre-open dispatch mirror; the native plane grants the cursor channel only where the resolved backend composites, `SessionPlan::output_format` keeps cursor-blend sessions off producer-native NV12, Vulkan RGB-direct yields even when pinned, and the GameStream portal source asks for cursor-as-metadata only when the backend blends (the capturer pool keys on that mode). Zero-copy is preserved throughout — every fallback is a capture-negotiation change, never a readback.
|
||||
- **The GameStream plane can now say a session ended.** `end_session` stops the media threads, and stopping a UDP sender is silence, not a signal — Moonlight holds the ENet control stream for the whole session and sat on its last frame until its own timeout fired. The control loop now watches for its session being cleared out from under it, sends the TERMINATION control message with `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, then `disconnect_later`s the peer so the queued message reaches the wire instead of racing teardown. Two details that are silent when wrong, both verified against moonlight-common-c's `ControlStream.c` rather than recalled: the reason is BIG-endian on the ≥6-byte branch (the short branch is a little-endian u16, GFE's older shape), and the packet type comes from the table the CLIENT chose — `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and we advertise 7.1.431, so it is `0x0109`, not `0x0100`. Deriving that from `NonceKind` was the error; the nonce scheme describes how the GCM nonce is built and nothing else, and the HDR message could never have caught it because `0x010e` is identical in both tables. A test pins `APP_VERSION` above 7.1.431, since dropping below it silently restores the wrong type. The session's GCM key is cached per tick because ending a session clears the launch state the key lives in.
|
||||
- **The direct-NVENC cursor blend is stream-ordered.** A cursor-bearing frame forced the CPU-synced submit path — a blocking CUDA copy plus a fence-waited Vulkan blend, both exposed to the running game's GPU load — on the assumption that games hide the pointer, which does not hold under gamescope, where the host composites the live pointer into every frame. Reported as an iPad on a 120 fps NVIDIA/gamescope session capped at ~80 fps with `repeat_fps` 0, zero loss, capture 0 µs, ASIC 15 µs and submit p50 at 10.2 ms — 81% of the loop period. `VkSlotBlend` now exports a timeline semaphore (`VK_KHR_timeline_semaphore` + `external_semaphore_fd`) into CUDA via `cuImportExternalSemaphore`: the enqueued copy signals it on the encode thread's copy stream, the blend waits and advances it on the Vulkan queue, and a CUDA-side wait orders the encode after the blend on the session's bound IO stream. Per-slot command buffers and descriptor sets keep several ordered blends in flight; drivers without the timeline export keep the CPU-synced path, and any failure degrades to "no cursor", never a dropped frame. The blocking multi-plane copies also pay one stream sync instead of one per plane (NV12 2→1, YUV444 3→1). Verified on an RTX 5070 Ti (driver 610.43.03): 12 on-hardware smokes green.
|
||||
- **The GameStream HDR SDR-downgrade latch had exactly one consumer** (`open_portal_monitor`, dropping the HDR offer) while the RTSP negotiation consulted only `gnome_hdr_monitor_active()` — so a set latch meant advertising HDR while capturing and encoding SDR, which the client renders as PQ. Consulted at RTSP honor time rather than folded into `host_hdr_capable()`, which is the static serverinfo capability.
|
||||
- **Phase 7 consolidations.** One Linux backend resolver (`resolve_linux_backend`) consumed by dispatch *and* the five partial hand-copies, matching what Windows has had; the slot-family and range-family RFI recovery policies extracted from three and two hand-copied twins respectively (the taint sweep had already reached AMF/QSV a commit before Vulkan was carved out, so Vulkan shipped without it); the AMF C-ABI mirror moved to `amf_sys.rs`; `vulkan_video.rs`'s ~820-line construction tail split into `vk_build.rs` (5,292 → 4,489 lines) — ⚠ with a trap recorded for future splits: an inline `use super::X` inside a moved fn body silently changes meaning. `TrackedEncoder`'s forwarding completeness is now guarded by a source-text set-equality test, a trap that has bitten three times (`set_wire_chunking`, `set_pipelined`, `applied_bitrate_bps` — a defaulted method the wrapper doesn't forward silently no-ops for every session).
|
||||
- **PyroWave's GPU selection stays first-usable, by decision.** Two selection designs died in adversarial review — matching `pf_gpu::selected_gpu()` moves the encoder off the iGPU that can import the compositor's dmabufs on an Intel-compositor + NVIDIA-present laptop, and anchoring on the render node picks the idle iGPU on the common AMD-iGPU + NVIDIA-display desktop, because render minors are driver-bind-order artifacts. The correct oracle is which device *allocated* the capture buffers, which needs per-session producer identity threaded through; until then the open logs one greppable line (picked vendor/device, anchor node and its owner, and the console's selected GPU) with no WARN arm, since the wrong-pick direction inverts between topologies.
|
||||
- **New tooling.** `scripts/wincheck.sh` type-checks and lints `#[cfg(target_os = "windows")]` Rust from a Linux box — a generated workspace whose members symlink `src` at the real crates plus a ~30-line stub `punktfunk-core`, keeping rustls/ring/opus out of the graph (the in-tree `--target x86_64-pc-windows-msvc` command dies in `audiopus_sys` and `ring` build scripts). Verified non-vacuous against a planted type error. Windows CI now *runs* pf-capture's tests rather than only type-checking them (18 of 19; the crate has no encoder dependency, so its test binary links against nothing the runner lacks). Also: `hdr-p010-selftest` silently ignored its size argument (`skip(2)` where the arm's own optionals start at index 1), so it had only ever validated 64×64 while printing PASS for `1920x1080`.
|
||||
- **aarch64 Linux client.** `deb.yml` cross-builds it on the ordinary amd64 runner via a new `punktfunk-rust-ci-arm64cross` image; the job hard-fails on a non-AArch64 session binary. skia's `aarch64-linux-gnu` textlayout+vulkan prebuilt resolves, so ARM ships the full client, OSD included. `ci.yml` gains an aarch64 clippy leg plus a `--no-default-features` session build — `c_char` signedness cuts both ways and neither direction is visible from an x86-only CI (hardcoding `i8` fails to compile on ARM; `as *mut u8` fails the lint there), and it has already caught two defects. The RPM spec gained `%bcond_without host` (verified by a real `rpmbuild` in a native aarch64 Fedora 43 container), the Arch PKGBUILD drops `punktfunk-host` from `pkgname` on aarch64, and the flatpak manifest is architecture-generic — but no aarch64 rpm/Arch/flatpak is published yet, and those builds are not cross-compiles.
|
||||
- **Encode CI coverage.** `nvenc` and `vulkan-encode` were previously linted by nothing — `enc/linux/nvenc_cuda.rs`, `enc/linux/vulkan_video.rs` and the vendored `vk_av1_encode` / `vk_valve_rgb` bindings, ~8,150 lines carrying ~70 `unsafe` blocks, so the crate's own `#![deny(clippy::undocumented_unsafe_blocks)]` was never enforced on them. Linux now lints and tests at the shipped feature set and Windows gets a `-p pf-encode --all-targets` leg (clippy rather than tests: MSVC link-imports the NVENC entry points). `--all-targets` is load-bearing — without it the feature-gated `#[cfg(test)]` modules are never compiled, which is how ten `E0061`s rotted undetected in the direct-NVENC test module. VAAPI and the Windows ffmpeg fallback gained unit-pinned decision logic (1,300 and 1,400 lines, 26 and 23 `unsafe`, zero tests before).
|
||||
- **The 19 vendored `#[repr(C)]` Vulkan structs gained const size/alignment/per-field-offset assertions**, plus three tests pinning bitfield member order (where a wrong index means the driver reads `use_superres` where we meant `render_and_frame_size_different`). Field order was diffed against `vulkan_core.h` and `vulkan_video_codec_av1std_encode.h` from Vulkan-Headers `main` as of 2026-07-25 — no drift.
|
||||
- **Windows NVENC teardown and session accounting.** Completion events are pushed to the teardown list before registration; a retrieve thread that has already blown one full 5 s completion budget drops later drains to 250 ms slices (routine teardown byte-identical — nothing is ever abandoned); and `LIVE_SESSION_UNITS` is refunded only on proof the driver released the session, with ambiguous failures parking the handle and `init_session` retrying the destroy under a gate that serialises against session opens.
|
||||
- **Resource-lifetime work across the Linux encode paths.** Every dmabuf-import failure after `create_image` leaked a `VkImage` and a dup'd fd — with the sharp edge that a successful `vkAllocateMemory` transfers fd ownership, so the naive close-on-error is a double close. PyroWave's `open_inner` had ~20 fallible steps each leaking everything before it, now constructed early with null resources so `Drop` is the single unwind path. `sws_getContext` leaked on both of `open`'s early returns — up to ~10 per session on the EINVAL bitrate ladder. The PyroWave NT-handle contract is pinned callee-side (patch 0006), so `import_plane`'s close-on-failure is correct on every path.
|
||||
- **Measured performance work.** The RGB-direct CPU upload builds the padded frame straight into mapped staging memory: 1080p `submit` p50 2315 → **1725 µs**, p99 2819 → **1972 µs** on a 780M, with an aligned-mode control run showing no delta. The packed 24-bpp expand moved from a `w*h` per-pixel loop to swscale's SIMD expanders (8.3M iterations per 4K frame, on the encode thread). Three per-frame costs came off the Windows HDR path, two of them inside the ring slot's keyed-mutex hold — plane RTVs and an immutable constant buffer are lifetime-of-mode facts, and a CCD `sdr_white_level_scale` query has no business contending the display-config lock while the driver's publisher is blocked. `std::env::var` came off three per-frame paths — worth recording that the audit ranked this as a hot-path defect and it measured **~122 ns/frame**, i.e. 0.003% of a frame budget; the fix is right, the severity was not.
|
||||
- **Bitrate honesty end to end.** `Encoder::applied_bitrate_bps()` exposes the post-clamp rate; the codec-level ceiling is cached per GPU/config so an overshoot opens at the ceiling instead of re-running a ~6-open binary search plus a rebuild + IDR; `SPLIT_FORCE_PIXEL_RATE` moved to 950 Mpix/s because 4K120 (995,328,000 px/s) missed the old `> 1e9` gate by 0.47% and stayed on AUTO, which never engages at 2160 px height. Client-side, two consecutive identical short acks latch a host rate cap (mode-scoped, re-probed after ~60 s parked clean), and the per-AU `0xCF` `encode_us` feeds the controller through its own window accumulator, baseline-relative. The latency escalation de-escalates on a ~5 s clean run at 120 fps with 1 → 5 → 25 min backoff; `PUNKTFUNK_NVENC_ASYNC=1` refuses the wind-back.
|
||||
- **`NV_ENC_ERR_INVALID_VERSION` is split on a `SESSION_OPENED` latch.** A version skew is static and cannot come and go inside one process, so a host that streams once per boot and then fails every later session is told to restart the host service, not to reboot. Diagnosis only — that field bug's root cause is still open.
|
||||
- **A bare gamescope spawn now backgrounds a splash client.** gamescope composites — and only on a composite pushes a capture buffer — when a client paints, and a nested Steam bootstrap paints nothing until its UI's first frame, far past the 10 s first-frame budget; the native plane's retry ladder then killed the half-booted Steam on every attempt and the GameStream plane died on its single wait. Root-caused on .41 with a raw `pw_stream` probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately. The new hidden `gamescope-splash` subcommand paints a breathing bar at ~2.5 Hz, so damage arrives from the first second on both planes. In `--steam` mode gamescope composites only windows whose appid is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a painting vkcube gets zero composites without it), so the splash declares `STEAM_GAME=769` and seeds the baselayer iff unset — Steam's own rewrite at game launch hands composite focus over with no action on our side. `PUNKTFUNK_GAMESCOPE_SPLASH=0` opts out. The dedicated launch also uses `-gamepadui` rather than `-silent`, since the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built around — and gamescope's focus rules (game outranks the Steam UI appid) cover what `-silent` was working around.
|
||||
- **The DM-stop takeover ships its privilege.** `libexec/punktfunk/pf-dm-helper` (verbs `stop`|`restore`) behind `io.unom.punktfunk.dm-helper` (`allow_any`, the same mechanism Nobara's `os-session-select` uses); the helper derives the DM unit from the `display-manager.service` symlink so callers never name a unit across the privilege boundary. Packaged in rpm/deb/arch; Nix keeps the manual polkit rule because store paths cannot match the probe.
|
||||
- **Other knobs and internals:** `PUNKTFUNK_DRM_CARD=<n>` pins SDL's KMSDRM device index; `PUNKTFUNK_ZEROCOPY_RENDER_NODE` overrides the NVIDIA render-node scan; `PUNKTFUNK_EXCLUSIVE_REASSERT_MS` (default 2 s, 0 disables) paces the Windows exclusive-topology watchdog; `PUNKTFUNK_VULKAN_RGB_DIRECT` and `PUNKTFUNK_PIPEWIRE_NV12` now share pf-host-config's `env_on()` grammar with four in-crate copies (both previously read a trailing space as force-ON); the libav log-level save/restore around the capability probes is one RAII guard over a shared mutex (four overlapping sites — interleaved save/restore pinned the process at `AV_LOG_FATAL` permanently); PyroWave's RDO block-index cap moved into `validate_dimensions` checked against 4:2:0 (8192×8192 = 98304 blocks overflows the 16-bit packing, and the host's only use of the helper actively routed oversized modes into the unguarded branch); the write-only `EncoderCaps::supports_hdr_metadata` is deleted (both planes send the static HDR grade out-of-band unconditionally and no first-party client parses in-band SEI); and `Encoder::flush` is documented as deliberately not wired into the production loops. Also, `@punktfunk/host`'s publish workflow is unblocked twice over: bun 1.3 installs a `file:` dependency by symlinking each top-level *file* to itself, so `package.json` arrived as a dangling self-reference and resolution died at the front door — and the step added to repair that was itself named `Repair the file: dependency (…)`, where the unquoted `file: ` reads as a second YAML mapping key, so the workflow had been unparseable (and therefore silently never running) since it landed.
|
||||
- **winget packaging.** Manifests ship in winget-pkgs' own format so upstream submission is later a copy rather than a rewrite, with a release-time generator substituting only version, URL, hash and notes link. The source is self-hosted — the community repo gates on Defender/SmartScreen validation the self-signed installer cert would not clear today — and implements three endpoints (`/information`, `/manifestSearch`, `/packageManifests/{id}`); the reference implementation's other twenty are its admin API for mutating a CosmosDB, and a catalogue generated at release time has nothing to mutate. It runs on unom-1 as a stock bun image with two bind-mounted `.mjs` files, the same shape as the flatpak server. The catalogue is derived from the RELEASES, not local files, because winget resolves `--version` and upgrade against the version list — a source that knew only the newest release could neither pin an older one nor show an upgrade path from it. `NormalizedPackageNameAndPublisher` is declared unsupported on purpose (winget derives it client-side with its own normalization, and a near-miss silently mis-correlates an installed host; `ProductCode` is exact and Inno gives us one). 28 checks drive the handler directly and CI gates on them, because a wrong response shape does not fail loudly — it just makes winget report "no package found".
|
||||
- **Installer idempotence.** `--gamestream` and `--allow-public-network` become fresh-install-only, keyed off whether `host.env` already exists; `service install` already read an absent `--gamestream` as "keep `host.env` as-is", and the public-network flag becomes tri-state the same way, resolved from the marker the previous install recorded. It is strict on the value: a typo'd `=of` must not fall through to a marker that may say true, which would turn a mistyped opt-OUT into leaving Public open. The conflicting-host warning moves from `MsgBox` (which ignores `/SUPPRESSMSGBOXES` and displays under `/VERYSILENT`) to `SuppressibleMsgBox` with an `IDNO` default.
|
||||
@@ -0,0 +1,53 @@
|
||||
Update whenever it suits you — the app and the machine you stream from can still be updated one at a time, and everything already paired keeps working. This release is entirely fixes and hardening on top of last release's session⇄game work — nothing new to opt into.
|
||||
|
||||
## Fixed: installing or updating the Windows host
|
||||
|
||||
Three separate reports came in within hours of the last release, all blocking install:
|
||||
|
||||
- **`winget install` could fail outright if you'd ever had another streaming host on the machine** — even one you had since disabled or mostly uninstalled. A leftover, disabled service or an empty folder from an old install was enough to trip the "conflicting host" check and abort. Only a host actually set to start on its own counts now.
|
||||
- **Installing through a package-manager app (like UniGetUI) could fail with a file-path error** before the install wizard ever appeared, because of a broken setting the installer passed itself. Fixed.
|
||||
- **The package source instructions were incomplete.** `winget install unom.PunktfunkHost` says "no package found" until you've pointed winget at Punktfunk's own source, once per machine — the command for that is now spelled out.
|
||||
|
||||
## Fixed: Moonlight compatibility no longer turns on without asking
|
||||
|
||||
A fresh Windows install used to enable Moonlight-app compatibility silently, with no checkbox ever shown — even though that mode pairs over a less secure connection and the host warns about it on every start. It's opt-in now. Existing installs are untouched either way; Punktfunk's own clients were never affected.
|
||||
|
||||
## Fixed: switching into Steam's Game Mode on Linux could black out the machine
|
||||
|
||||
Last release shipped the permission needed to properly take over the screen for Steam's Game Mode — and surfaced a few sharp edges that only show up once that takeover is actually live:
|
||||
|
||||
- **The takeover could kill Punktfunk's own background service a few seconds later**, leaving the display dark with nothing left to bring it back except a physical console or SSH.
|
||||
- **It could also hang waiting on a permission prompt nobody could see**, delaying recovery until well after the stream had already dropped — or act on a leftover marker file from months ago and jump the machine to the desktop for no reason.
|
||||
- **Restarting the host mid-stream** — including a routine update — **could leave the display stuck off**, since nothing told the screen to come back.
|
||||
|
||||
All three are fixed. Recognizing the machine's built-in Steam Game Mode may now need a one-time permission on some distributions; installing or updating normally takes care of it.
|
||||
|
||||
## Fixed: a laptop with its built-in screen turned off stuttered constantly
|
||||
|
||||
On a laptop with two graphics chips and the internal screen switched off — a common setup for streaming from a laptop docked to a TV or monitor — streaming stuttered roughly every two seconds, for the whole session. Punktfunk now recognizes a dark-but-connected laptop screen as the cause and points at the actual fix, instead of pointing at an external monitor that isn't the problem.
|
||||
|
||||
## Fixed: latency creeping up over the course of a fast, high-bitrate stream
|
||||
|
||||
On a fast local network, especially at higher bitrates, latency could climb steadily over a long session instead of staying flat. Several separate causes are fixed at once: the stream's internal clock sync could get starved by a busy connection and let the two ends' clocks drift; a stream mode could put more data on the wire than its bitrate setting actually allowed; a slow-to-decode moment could build up a backlog that took a while to work through instead of catching up immediately; and the one-time startup speed test could be mistaken for real network congestion and permanently cap the stream below what the connection could actually handle.
|
||||
|
||||
## Fixed: missing cover art looked broken
|
||||
|
||||
A library entry with no cover art showed as an empty grey box, which read as something that failed to load. It now shows a controller icon instead, so "no art yet" looks intentional rather than broken.
|
||||
|
||||
## Improved: the Windows app now keeps its own log file
|
||||
|
||||
The desktop app writes its own log to `%LOCALAPPDATA%\punktfunk\logs\client.log` now, instead of only printing to a console window that a normal launch never shows. If you're ever asked to send logs for a problem, this is where they are.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- **Nothing changed on the wire or at the API boundary.** Streaming protocol stays at version **2**, the embeddable C ABI at **13**, the Windows virtual-display driver protocol at **6** — 0.18–0.20 hosts and clients keep mixing freely.
|
||||
- **Windows conflict detection** now keys on the competing service's `Start` registry value (0–2 = boot/system/auto only), matching the tray's own dormant-is-harmless logic from last release; install directories no longer count.
|
||||
- **The winget `Log` switch used the literal string `|LOGPATH|`** instead of winget's actual `<LOGPATH>` substitution token, so Inno received an illegal filename on any install that requested a log (UniGetUI does by default). Guarded by a new manifest check.
|
||||
- **`gamestream` installer task lost its default-checked flag**; a silent/unattended install now lands `PUNKTFUNK_HOST_CMD=serve`. `/MERGETASKS=gamestream` opts in. Fresh installs only — `GamestreamParam` already omitted the flag on upgrades.
|
||||
- **The gamescope display-manager takeover** now calls privileged `systemctl` verbs non-interactively (`--no-ask-password`) so a headless polkit prompt can no longer block the stream's own recovery thread; `session_select_requested`'s sentinel baseline is now recorded at takeover time as well as at successful launch, closing a window where a months-old `~/.config/steamos-session-select` write could false-positive a session-select honor; and `punktfunk-host` now handles SIGTERM/SIGINT by restoring the display manager it stopped (20 s grace) before exiting, so a `systemctl --user restart` or package upgrade mid-takeover can't strand the box. The takeover also now ensures the invoking user has systemd lingering enabled before touching the display manager — without it, stopping the DM stops the user's own service manager ~10 s later and takes the host down with it.
|
||||
- **A new `internal_panel` target class** lets the exclusive-topology stall detector name a deactivated eDP/LVDS laptop panel as a disturbance suspect; previously the suspect list was filtered to external physicals only, so the one display responsible on a hybrid laptop reported as none. `EvtIddCxMonitorI2CTransmit/Receive` now answer every DDC/CI probe against the virtual monitor with an immediate `STATUS_NOT_SUPPORTED`, so third-party brightness/monitor tools stop occupying win32k's serialized physical-monitor path on it. The swap-chain drain thread now falls back to `TIME_CRITICAL` priority if MMCSS registration is refused, and raises `IddMinimumVersionRequired` 4 → 10 to use `IddCxSetRealtimeGPUPriority`.
|
||||
- **Clock re-sync** now spaces its probe rounds ~7 ms apart, tracks a running best-RTT guard band instead of a static connect-time floor, and applies the best batch of a rejection streak after three consecutive rejections rather than drifting unbounded.
|
||||
- **A new per-frame `WireBudget`** (shared between encoder backends) tracks the real bitstream-to-wire inflation ratio for datagram-aligned PyroWave sessions and deflates the rate-control target by it, so the configured bitrate pin holds on the wire rather than on the raw codec bitstream.
|
||||
- **The pre-decode frame channel now drains to the newest AU for all-intra (PyroWave) streams**, since those have no reference chain forbidding a mid-stream drop — capping any standing backlog at roughly one frame instead of ratcheting between the coarse jump-to-live thresholds.
|
||||
- **The ABR capacity probe's first post-probe report window is now discarded outright**, rather than measured, so the probe's own deliberate overload can no longer read as session congestion and permanently end slow-start early.
|
||||
- New `display-disturb` dev tool for reproducing DDC/CI and modeset-class display disturbances on demand during stall-immunity testing; not shipped to end users.
|
||||
@@ -680,6 +680,11 @@
|
||||
#define ClockResync_ROUNDS 8
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Consecutive rejected batches tolerated before the best of them is applied anyway.
|
||||
#define ResyncGuard_MAX_REJECTED_STREAK 3
|
||||
#endif
|
||||
|
||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||
#define MSG_RECONFIGURE 1
|
||||
|
||||
@@ -60,10 +60,14 @@ parse breakage that silently failed installs on non-English boxes.
|
||||
**`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd` → `bun` on
|
||||
`:47992`), opens TCP 47992, and starts it. It proxies the host's loopback mgmt API with the host's
|
||||
own `%ProgramData%\punktfunk\mgmt-token`.
|
||||
- **GameStream (Moonlight) compatibility is a wizard task** (checked by default): the choice is passed
|
||||
to `service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
|
||||
`serve`, the secure native-only host) into `host.env`. Upgrade-safe: a hand-customized
|
||||
`PUNKTFUNK_HOST_CMD` is never overwritten.
|
||||
- **GameStream (Moonlight) compatibility is a wizard task** (**unchecked** by default — it pairs over
|
||||
plain HTTP, so it is opt-in like the Public-firewall task): the choice is passed to
|
||||
`service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
|
||||
`serve`, the secure native-only host) into `host.env`. Unattended, add it with
|
||||
`/MERGETASKS=gamestream`. Upgrade-safe: a hand-customized `PUNKTFUNK_HOST_CMD` is never
|
||||
overwritten, and on an upgrade the task is inert entirely (the flag is omitted, so `host.env`
|
||||
keeps whatever it already says) — change an existing host with
|
||||
`punktfunk-host service install --gamestream=on|off` plus a service restart.
|
||||
- **Branded, modern wizard**: `WizardStyle=modern dynamic windows11` (Inno ≥ 6.6 — Windows-11-style
|
||||
controls following the system light/dark theme; pre-6.6 compilers fall back to plain `modern`), with
|
||||
the punktfunk lens mark on the side panel / header tile and a multi-size `punktfunk.ico`
|
||||
|
||||
@@ -375,3 +375,30 @@ pub unsafe extern "C" fn device_io_control(
|
||||
// SAFETY: `request` is the framework-provided WDFREQUEST; `control::dispatch` completes it exactly once.
|
||||
unsafe { crate::control::dispatch(request, ioctl_code) };
|
||||
}
|
||||
|
||||
/// DDC/CI transmit toward the virtual monitor — refuse INSTANTLY (stall-immunity: DDC fail-fast).
|
||||
///
|
||||
/// In exclusive topology the virtual display is the ONLY monitor on the desktop, so
|
||||
/// monitor-control software (the Twinkle Tray / PowerToys PowerDisplay / Monitorian class) aims
|
||||
/// its entire DDC traffic — brightness polls, capabilities-string requests — at THIS monitor.
|
||||
/// There is no bus and no sink here; the only wrong answer is a slow one (a timeout-shaped
|
||||
/// failure occupies win32k's physical-monitor path, serialized per monitor, for its full
|
||||
/// duration). Registering the pair pins every probe's cost to one immediate
|
||||
/// `STATUS_NOT_SUPPORTED`. EDID needs no equivalent: the OS serves descriptor queries from the
|
||||
/// blob supplied at monitor creation without calling the driver.
|
||||
pub unsafe extern "C" fn monitor_i2c_transmit(
|
||||
_monitor: iddcx::IDDCX_MONITOR,
|
||||
_p_in: *const iddcx::IDARG_IN_I2C_TRANSMIT,
|
||||
) -> NTSTATUS {
|
||||
crate::STATUS_NOT_SUPPORTED
|
||||
}
|
||||
|
||||
/// DDC/CI receive from the virtual monitor — same contract as [`monitor_i2c_transmit`]. (The
|
||||
/// receive DDI has no out-arg at the callback — data would flow back through an async completion;
|
||||
/// a synchronous failure return refuses the whole transaction, which is exactly the point.)
|
||||
pub unsafe extern "C" fn monitor_i2c_receive(
|
||||
_monitor: iddcx::IDDCX_MONITOR,
|
||||
_p_in: *const iddcx::IDARG_IN_I2C_RECEIVE,
|
||||
) -> NTSTATUS {
|
||||
crate::STATUS_NOT_SUPPORTED
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
cfg.EvtIddCxMonitorSetGammaRamp = Some(callbacks::set_gamma_ramp);
|
||||
cfg.EvtIddCxMonitorAssignSwapChain = Some(callbacks::assign_swap_chain);
|
||||
cfg.EvtIddCxMonitorUnassignSwapChain = Some(callbacks::unassign_swap_chain);
|
||||
// DDC fail-fast (stall-immunity): monitor-control software polls the virtual monitor's DDC —
|
||||
// in exclusive topology it is the ONLY monitor. These answer every probe with an immediate
|
||||
// STATUS_NOT_SUPPORTED instead of whatever slow path an unregistered interface takes.
|
||||
cfg.EvtIddCxMonitorI2CTransmit = Some(callbacks::monitor_i2c_transmit);
|
||||
cfg.EvtIddCxMonitorI2CReceive = Some(callbacks::monitor_i2c_receive);
|
||||
cfg.EvtIddCxDeviceIoControl = Some(callbacks::device_io_control);
|
||||
|
||||
// SAFETY: init is the framework device-init; cfg is fully populated + sized. (Links IddCxStub.)
|
||||
|
||||
@@ -35,12 +35,22 @@ use wdk_sys::NTSTATUS;
|
||||
// NTSTATUS codes the driver returns (wdk-sys doesn't surface all of these as constants).
|
||||
pub(crate) const STATUS_SUCCESS: NTSTATUS = 0;
|
||||
pub(crate) const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
|
||||
pub(crate) const STATUS_NOT_SUPPORTED: NTSTATUS = 0xC000_00BBu32 as NTSTATUS;
|
||||
pub(crate) const STATUS_NOT_FOUND: NTSTATUS = 0xC000_0225u32 as NTSTATUS;
|
||||
pub(crate) const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
|
||||
pub(crate) const STATUS_BUFFER_TOO_SMALL: NTSTATUS = 0xC000_0023u32 as NTSTATUS;
|
||||
|
||||
/// IddCx (stub mode) requires the driver to export the minimum IddCx framework version it needs — the
|
||||
/// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` that normally emits it is compiled out under
|
||||
/// `IDD_STUB`. `4` matches the proven `wdf-umdf` oracle.
|
||||
/// `IDD_STUB`.
|
||||
///
|
||||
/// `10` = IddCx 1.10, the TRUTHFUL floor: the drain loop calls
|
||||
/// `IddCxSwapChainReleaseAndAcquireBuffer2` (1.10) unconditionally and the swap-chain worker uses
|
||||
/// `IddCxSetRealtimeGPUPriority` (1.9); the product floor is already Windows 11 22H2 / build 22621,
|
||||
/// whose framework is 1.10 (the installer gate — `MinVersion=10.0.22621` in punktfunk-host.iss —
|
||||
/// exists precisely for this driver). The oracle's historical `4` predated that floor and would let
|
||||
/// the driver load against a SHORT `IddFunctions` table, where dispatching any post-1.4 DDI reads
|
||||
/// past the populated entries. With `10`, an older framework fails the bind cleanly (driver doesn't
|
||||
/// load) instead of limping into undefined dispatch.
|
||||
#[unsafe(no_mangle)]
|
||||
pub static IddMinimumVersionRequired: wdk_sys::ULONG = 4;
|
||||
pub static IddMinimumVersionRequired: wdk_sys::ULONG = 10;
|
||||
|
||||
@@ -29,8 +29,8 @@ use std::{
|
||||
};
|
||||
|
||||
use wdk_sys::iddcx::{
|
||||
IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SWAPCHAINSETDEVICE,
|
||||
IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN,
|
||||
IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SETREALTIMEGPUPRIORITY,
|
||||
IDARG_IN_SWAPCHAINSETDEVICE, IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN,
|
||||
};
|
||||
// `HANDLE` is the shared wdk-sys typedef (`crate::types`) re-used by the iddcx bindings — take it from
|
||||
// the crate root, which is guaranteed to export it (the iddcx module only re-exports it if bindgen
|
||||
@@ -44,7 +44,8 @@ use windows::{
|
||||
Dxgi::{IDXGIDevice, IDXGIResource},
|
||||
},
|
||||
System::Threading::{
|
||||
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, WaitForSingleObject,
|
||||
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, GetCurrentThread,
|
||||
SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL, WaitForSingleObject,
|
||||
},
|
||||
},
|
||||
core::{Interface, w},
|
||||
@@ -125,12 +126,25 @@ impl SwapChainProcessor {
|
||||
// MMCSS can fail under the restricted WUDFHost token ('Distribution' task unregistered /
|
||||
// service unavailable). The MS sample CONTINUES unprioritized — never abort: returning
|
||||
// here would leave the assigned swap-chain undrained (the monitor stalls, DWM blocks on
|
||||
// it) and leak the WDF swap-chain object until device teardown.
|
||||
// it) and leak the WDF swap-chain object until device teardown. But "unprioritized" is
|
||||
// not acceptable either: this thread is the whole display's frame pump, and at normal
|
||||
// priority a display-stack disturbance (DDC/HPD servicing DPC pressure, poller-software
|
||||
// storms) can starve it into multi-hundred-ms delivery holes. Fall back to
|
||||
// TIME_CRITICAL — the highest band available without the realtime priority class, and
|
||||
// the closest to what MMCSS 'Distribution' would have granted. The thread spends its
|
||||
// life blocked on the surface-available event / keyed mutex, so it cannot starve others.
|
||||
let av_handle = match res {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
// SAFETY: plain FFI; GetCurrentThread returns a pseudo-handle (never fails,
|
||||
// nothing to close), SetThreadPriority on it affects only this thread.
|
||||
let fallback = unsafe {
|
||||
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL)
|
||||
};
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — continuing unprioritized"
|
||||
"[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — fell back to \
|
||||
TIME_CRITICAL thread priority (ok={})",
|
||||
fallback.is_ok()
|
||||
);
|
||||
None
|
||||
}
|
||||
@@ -229,6 +243,29 @@ impl SwapChainProcessor {
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// IddCx 1.9 realtime GPU scheduling priority for the processing device (stall-immunity
|
||||
// program, branch-2 hardening): swap-chain buffer processing outruns ordinary GPU
|
||||
// contention — "higher priority than any regular application can set". The slot is
|
||||
// guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs); the DDI itself may
|
||||
// still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware) — best-effort, never fatal.
|
||||
// Called while our borrowed device reference is still alive; IddCx uses it synchronously.
|
||||
if set_ok {
|
||||
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
|
||||
rt.pDevice = dxgi_device.as_raw().cast();
|
||||
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose device
|
||||
// bind just succeeded; `rt.pDevice` is that same bound DXGI device, alive across the
|
||||
// synchronous call; `rt` points to valid local storage.
|
||||
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
|
||||
if hr_success(hr) {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (target={target_id})"
|
||||
);
|
||||
} else {
|
||||
dbglog!(
|
||||
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit drop
|
||||
// so NLL can't release it mid-loop while the swap-chain still references the raw ptr.)
|
||||
drop(dxgi_device);
|
||||
|
||||
@@ -200,3 +200,20 @@ iddcx_ddi!(
|
||||
IddCxSwapChainFinishedProcessingFrame(swap_chain: iddcx::IDDCX_SWAPCHAIN)
|
||||
@ IddCxSwapChainFinishedProcessingFrameTableIndex as PFN_IDDCXSWAPCHAINFINISHEDPROCESSINGFRAME
|
||||
);
|
||||
|
||||
iddcx_ddi!(
|
||||
/// Raise the swap-chain's processing D3D device to realtime GPU scheduling priority — "higher
|
||||
/// than any regular application can set" (IddCx 1.9) — so buffer processing outruns ordinary
|
||||
/// GPU contention. It does NOT help against adapter-wide display servicing (modeset-class DDIs
|
||||
/// idle the hardware outright); it defends the contention case only. Best-effort at the call
|
||||
/// site: the DDI itself may decline (e.g. E_NOTIMPL on WDDM < 3.0 hardware).
|
||||
///
|
||||
/// Table-slot availability rests on the driver's `IddMinimumVersionRequired = 10` export
|
||||
/// (pf-vdisplay lib.rs): the loader refuses to bind a framework older than IddCx 1.10, so
|
||||
/// every slot of our compiled 1.10 surface — this 1.9 one included — is populated wherever the
|
||||
/// driver runs at all.
|
||||
IddCxSetRealtimeGPUPriority(
|
||||
swap_chain: iddcx::IDDCX_SWAPCHAIN,
|
||||
in_args: *const iddcx::IDARG_IN_SETREALTIMEGPUPRIORITY,
|
||||
) @ IddCxSetRealtimeGPUPriorityTableIndex as PFN_IDDCXSETREALTIMEGPUPRIORITY
|
||||
);
|
||||
|
||||
@@ -88,12 +88,12 @@
|
||||
|
||||
[Setup]
|
||||
AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}
|
||||
AppName=punktfunk host
|
||||
AppName=Punktfunk Host
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher=unom
|
||||
AppPublisherURL=https://git.unom.io/unom/punktfunk
|
||||
DefaultDirName={autopf}\punktfunk
|
||||
DefaultGroupName=punktfunk
|
||||
DefaultGroupName=Punktfunk
|
||||
DisableProgramGroupPage=yes
|
||||
UsePreviousAppDir=yes
|
||||
PrivilegesRequired=admin
|
||||
@@ -123,7 +123,7 @@ WizardStyle=modern
|
||||
SetupIconFile={#BrandingDir}\punktfunk.ico
|
||||
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
|
||||
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
|
||||
UninstallDisplayName=punktfunk host {#MyAppVersion}
|
||||
UninstallDisplayName=Punktfunk Host {#MyAppVersion}
|
||||
; The branded multi-size .ico (installed below). The host exe now embeds the same icon + a
|
||||
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
|
||||
; copy stays as the uninstall-entry icon.
|
||||
@@ -140,7 +140,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
; Shown when MinVersion rejects the OS — name the actual requirement instead of Inno's generic
|
||||
; "requires Windows version 10.0.22621" (users on Windows 10 LTSC hit this; see the pf-vdisplay
|
||||
; IddCx 1.10 note at MinVersion above).
|
||||
WinVersionTooLowError=punktfunk host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
|
||||
WinVersionTooLowError=Punktfunk Host requires Windows 11 22H2 (build 22621) or newer.%n%nIts virtual display driver needs the IddCx 1.10 framework, which is not available on older Windows — including all editions of Windows 10 (LTSC too) and Windows 11 21H2.
|
||||
|
||||
[Tasks]
|
||||
#ifdef WithDriver
|
||||
@@ -160,18 +160,30 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
|
||||
#endif
|
||||
; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD
|
||||
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
|
||||
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
|
||||
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
|
||||
; host; unchecked (DEFAULT) = the secure native-only host (Punktfunk clients only).
|
||||
;
|
||||
; OPT-IN, like allowpublicfw below and for the same reason: the host itself WARNs on every start
|
||||
; that this plane pairs over plain HTTP and its legacy control encryption can reuse GCM nonces
|
||||
; (security-review #5/#9), so an on-path LAN attacker could MITM pairing or recover input. A
|
||||
; default-on security downgrade cannot be squared with that warning - least of all on the silent
|
||||
; path, where the wizard never appears and 1839d756 makes an unattended install take these very
|
||||
; defaults. Reported by a user who found the warning in their log and had never been shown a
|
||||
; choice, because they installed through winget.
|
||||
;
|
||||
; Turning it on unattended is `/MERGETASKS="gamestream"`; on an UPGRADE this task is inert either
|
||||
; way (GamestreamParam omits the flag unless FreshHostInstall), so an existing host keeps whatever
|
||||
; host.env already says - changing it afterwards is `service install --gamestream=on|off`.
|
||||
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"; Flags: unchecked
|
||||
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
|
||||
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
|
||||
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
|
||||
; some headless / no-gateway LAN setups) - it opens the streaming + console ports on Public too.
|
||||
Name: "allowpublicfw"; Description: "Allow connections on Public networks (only for a trusted network Windows marks as Public)"; Flags: unchecked
|
||||
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
|
||||
Name: "startservice"; Description: "Start the Punktfunk Host service now (also starts on every boot)"
|
||||
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
|
||||
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
|
||||
; in to this host box gets one (each session keeps exactly one via a Local\ mutex).
|
||||
Name: "trayicon"; Description: "Show the punktfunk status icon in the notification area at sign-in"
|
||||
Name: "trayicon"; Description: "Show the Punktfunk status icon in the notification area at sign-in"
|
||||
|
||||
[Files]
|
||||
Source: "{#BinDir}\punktfunk-host.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
@@ -280,15 +292,15 @@ Filename: "powershell.exe"; \
|
||||
; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}.
|
||||
; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD.
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
||||
StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated
|
||||
StatusMsg: "Registering the Punktfunk Host service..."; Flags: runhidden waituntilterminated
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \
|
||||
StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
|
||||
StatusMsg: "Starting the Punktfunk Host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
|
||||
#ifdef WithWeb
|
||||
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
|
||||
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
|
||||
; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
|
||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
||||
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
|
||||
StatusMsg: "Setting up the Punktfunk web console..."; Flags: runhidden waituntilterminated
|
||||
#endif
|
||||
#ifdef WithScripting
|
||||
; Register the plugin/script runner's scheduled task (boot, restart-on-failure) but leave it
|
||||
@@ -302,7 +314,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParam
|
||||
; in the command, so no Inno {{ }} escaping needed.
|
||||
Filename: "powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""$a=New-ScheduledTaskAction -Execute '{app}\scripting\scripting-run.cmd'; $t=New-ScheduledTaskTrigger -AtStartup; $p=New-ScheduledTaskPrincipal -UserId 'LocalService' -LogonType ServiceAccount; $s=New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries; Register-ScheduledTask -TaskName PunktfunkScripting -Action $a -Trigger $t -Principal $p -Settings $s -Force -ErrorAction SilentlyContinue | Out-Null; Disable-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue | Out-Null"""; \
|
||||
StatusMsg: "Registering the punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
||||
StatusMsg: "Registering the Punktfunk script runner (disabled; opt-in)..."; Flags: runhidden waituntilterminated
|
||||
#endif
|
||||
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
||||
; icon appears without waiting for the next sign-in.
|
||||
@@ -341,15 +353,44 @@ Filename: "powershell.exe"; \
|
||||
#endif
|
||||
|
||||
[Code]
|
||||
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
|
||||
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under
|
||||
Program Files, so a registry + directory probe catches them without enumerating processes (which
|
||||
pure Pascal can't do cleanly). }
|
||||
function StreamHostPresent(SvcKey, DirName: String): Boolean;
|
||||
{ Captured in InitializeSetup - BEFORE [Run] calls `service install`, which creates host.env. True on
|
||||
a first-ever install, False on an upgrade. Gates the install-time-only CONFIG choices (see
|
||||
GamestreamParam): a silent run has no wizard on screen, so every task checkbox falls back to its
|
||||
script default. Re-applying those defaults on an upgrade would overwrite a setting the user chose
|
||||
on an earlier run, with nothing shown. `winget upgrade` runs us exactly that way. }
|
||||
var
|
||||
FreshHostInstall: Boolean;
|
||||
|
||||
function HostEnvPath: String;
|
||||
begin
|
||||
Result := RegKeyExists(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey)
|
||||
or DirExists(ExpandConstant('{commonpf}\' + DirName))
|
||||
or DirExists(ExpandConstant('{commonpf32}\' + DirName));
|
||||
Result := ExpandConstant('{commonappdata}\punktfunk\host.env');
|
||||
end;
|
||||
|
||||
{ True if another Moonlight-compatible streaming host is not merely present but will actually RUN.
|
||||
Sunshine and its forks register a "<Name>Service"; `Start` is that service's start type
|
||||
(REG_DWORD): 0 boot, 1 system, 2 automatic, 3 manual, 4 disabled. Only 0-2 come up on their own,
|
||||
and only a host that comes up can take the GameStream ports or load a second virtual-display
|
||||
driver - which is the entire content of the warning below.
|
||||
|
||||
DELIBERATELY NARROWER than it was. The old probe also counted the service key existing at ANY
|
||||
start type, plus a bare Program Files\<Name> directory - so a disabled service, or a leftover folder
|
||||
from an uninstall, read as a live conflict. Combined with the silent-install default of IDNO that
|
||||
aborted setup, and a field report followed within hours of 0.20.0: `winget install
|
||||
unom.PunktfunkHost` failed with exit code 1 (0x8A150006) on a box whose Sunshine was not running.
|
||||
Nothing about a dormant install can clash, and the tray reached the same conclusion in this same
|
||||
release (3e782852 dropped its always-on warning over a merely-INSTALLED Sunshine as a false
|
||||
alarm) - the two surfaces now agree.
|
||||
|
||||
A host running as a plain user process rather than a service is not detected here, by choice:
|
||||
that cannot be seen from pure Pascal, it is a runtime condition rather than an install-time one,
|
||||
and the host already reports it where it belongs (the `punktfunk::detect` startup warning, the
|
||||
`detect-conflicts` subcommand, and /api/v1/local/summary). }
|
||||
function StreamHostEnabled(SvcKey: String): Boolean;
|
||||
var
|
||||
StartType: Cardinal;
|
||||
begin
|
||||
Result := RegQueryDWordValue(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey, 'Start', StartType)
|
||||
and (StartType <= 2);
|
||||
end;
|
||||
|
||||
{ Runs before any wizard page - the earliest point we can warn. Detect a conflicting host and let
|
||||
@@ -359,30 +400,49 @@ var
|
||||
Found: String;
|
||||
begin
|
||||
Result := True;
|
||||
{ Record the fresh-vs-upgrade verdict while host.env still reflects the PREVIOUS run. }
|
||||
FreshHostInstall := not FileExists(HostEnvPath);
|
||||
Found := '';
|
||||
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10;
|
||||
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10;
|
||||
if StreamHostPresent('VibeshineService', 'Vibeshine') then Found := Found + ' - Vibeshine' + #13#10;
|
||||
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
|
||||
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
|
||||
if StreamHostEnabled('SunshineService') then Found := Found + ' - Sunshine' + #13#10;
|
||||
if StreamHostEnabled('ApolloService') then Found := Found + ' - Apollo' + #13#10;
|
||||
if StreamHostEnabled('VibeshineService') then Found := Found + ' - Vibeshine' + #13#10;
|
||||
if StreamHostEnabled('VibepolloService') then Found := Found + ' - Vibepollo' + #13#10;
|
||||
if StreamHostEnabled('LuminalShineService') then Found := Found + ' - LuminalShine' + #13#10;
|
||||
if Found <> '' then
|
||||
Result := MsgBox(
|
||||
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 +
|
||||
{ SuppressibleMsgBox, NOT MsgBox: a plain MsgBox ignores /SUPPRESSMSGBOXES and displays even
|
||||
under /VERYSILENT - i.e. an unattended install (winget) would block on a modal dialog with no
|
||||
wizard on screen and nobody to click it. Suppressed, this returns Default = IDNO, so a silent
|
||||
install onto a box that already runs Sunshine/Apollo ABORTS (Setup exits non-zero) instead of
|
||||
proceeding into the unsupported dual-host state the message describes. }
|
||||
Result := SuppressibleMsgBox(
|
||||
{ NB: keep #13#10 off the START of a line - ISPP reads a leading '#' as a preprocessor
|
||||
directive and aborts the compile with "Unknown preprocessor directive". }
|
||||
'Another game-streaming host is installed on this PC and set to start automatically:' + #13#10#13#10 + Found + #13#10 +
|
||||
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
|
||||
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
|
||||
'install a conflicting virtual-display driver, which causes pairing failures, "address ' +
|
||||
'already in use" errors and capture glitches.' + #13#10#13#10 +
|
||||
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 +
|
||||
'Stop and disable its service (or uninstall it) before using Punktfunk. A host that is ' +
|
||||
'installed but disabled does not clash and is not reported here.' + #13#10#13#10 +
|
||||
'Continue with the installation anyway?',
|
||||
mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES;
|
||||
mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES;
|
||||
end;
|
||||
|
||||
{ The GameStream task choice, forwarded to `service install` (which writes host.env's
|
||||
PUNKTFUNK_HOST_CMD - only if it is unset or still one of the two canonical values, so a
|
||||
hand-customized command line survives upgrades). }
|
||||
hand-customized command line survives upgrades).
|
||||
|
||||
FRESH INSTALL ONLY. On an upgrade the flag is omitted entirely, which `service install` reads as
|
||||
"keep host.env as-is" (windows/service.rs: "None = flag absent, keep host.env as-is"). Passing an
|
||||
explicit on/off would rewrite PUNKTFUNK_HOST_CMD whenever it still holds either canonical value -
|
||||
so a user who chose GameStream ON, then upgraded with the task unchecked (which is what EVERY
|
||||
silent run does, since there is no wizard to carry the old choice forward), would have it turned
|
||||
OFF with nothing on screen. Only a hand-edited command line survived that. }
|
||||
function GamestreamParam(Param: String): String;
|
||||
begin
|
||||
if WizardIsTaskSelected('gamestream') then
|
||||
if not FreshHostInstall then
|
||||
Result := ''
|
||||
else if WizardIsTaskSelected('gamestream') then
|
||||
Result := '--gamestream=on'
|
||||
else
|
||||
Result := '--gamestream=off';
|
||||
@@ -392,13 +452,21 @@ end;
|
||||
(default = Private/Domain only). Forwarded to both `service install` and `web setup`. Returns a
|
||||
LEADING SPACE so it concatenates after the preceding code-substitution param without a gap.
|
||||
(Do NOT write a literal code-constant token in this comment: Inno's brace comments do not nest,
|
||||
so its closing brace would end the comment early and break the [Code] parse.) }
|
||||
so its closing brace would end the comment early and break the [Code] parse.)
|
||||
|
||||
FRESH INSTALL ONLY, for the same reason as GamestreamParam: on an upgrade the flag is omitted and
|
||||
both `service install` and `web setup` resolve the scope from the marker the previous install
|
||||
recorded. Re-applying the task default would re-scope the firewall on every upgrade - and since
|
||||
this task is default-UNCHECKED, a silent upgrade would silently REVOKE a Public opt-in the user
|
||||
made once. The explicit =on/=off form is used so a fresh install still states its choice. }
|
||||
function PublicFwParam(Param: String): String;
|
||||
begin
|
||||
if WizardIsTaskSelected('allowpublicfw') then
|
||||
Result := ' --allow-public-network'
|
||||
if not FreshHostInstall then
|
||||
Result := ''
|
||||
else if WizardIsTaskSelected('allowpublicfw') then
|
||||
Result := ' --allow-public-network=on'
|
||||
else
|
||||
Result := '';
|
||||
Result := ' --allow-public-network=off';
|
||||
end;
|
||||
|
||||
#ifdef WithWeb
|
||||
@@ -443,7 +511,7 @@ var
|
||||
begin
|
||||
FreshWebInstall := not FileExists(WebPasswordPath);
|
||||
WebPwPage := CreateInputQueryPage(wpSelectTasks,
|
||||
'Web console', 'Set the punktfunk web console login password',
|
||||
'Web console', 'Set the Punktfunk web console login password',
|
||||
'The management console is served on https://this-computer:47992 and is login-gated. Keep the ' +
|
||||
'secure password generated below (it is shown again on the final page) or enter your own - you ' +
|
||||
'can change it later in %ProgramData%\punktfunk\web-password.');
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# winget manifests — Windows host
|
||||
|
||||
The reviewed source of truth for the `unom.PunktfunkHost` winget package. Everything except
|
||||
`PackageVersion` / `InstallerUrl` / `InstallerSha256` / `ReleaseNotesUrl` is edited **here**;
|
||||
`scripts/ci/winget-manifest.ps1` only substitutes those four per release, so the switches,
|
||||
agreements and installation notes stay under normal code review.
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `unom.PunktfunkHost.yaml` | Version manifest — ties the other two together. |
|
||||
| `unom.PunktfunkHost.installer.yaml` | Installer type, scope, silent switches, `ProductCode`, URL + hash. |
|
||||
| `unom.PunktfunkHost.locale.en-US.yaml` | User-facing metadata, `Agreements`, `InstallationNotes`. |
|
||||
|
||||
## Why these choices
|
||||
|
||||
- **`InstallerType: inno`, `Scope: machine`, `ElevationRequirement: elevatesSelf`.** The host
|
||||
registers a SYSTEM service, installs drivers and opens firewall ports; `PrivilegesRequired=admin`
|
||||
in the `.iss` means Setup raises its own UAC prompt. There is no per-user scope.
|
||||
- **`ProductCode: {7C9E6A52-…}_is1`** — Inno's ARP key is `<AppId>_is1`. This is what correlates an
|
||||
installed host with the package for `winget list` / `winget upgrade`. **It must track `AppId` in
|
||||
`packaging/windows/punktfunk-host.iss`** — if that GUID ever changes, change it here too or
|
||||
upgrades silently stop being detected.
|
||||
- **`interactive` is in `InstallModes`.** `winget install unom.PunktfunkHost --interactive` runs the
|
||||
full existing wizard: every task checkbox, the web-console password page, the VB-CABLE notice.
|
||||
Nothing about the installer changes to support it.
|
||||
- **No `/MERGETASKS` in the silent switches.** A silent install deliberately takes the *same* task
|
||||
defaults the wizard shows, so the product does not differ by install channel — a per-channel
|
||||
default is a support trap ("it works when I install it by hand"). The disclosures the wizard puts
|
||||
on screen are carried by `Agreements` instead, which winget shows *before* install and requires
|
||||
the user to accept.
|
||||
- **`UpgradeBehavior: install`** — Inno upgrades in place (`UsePreviousAppDir=yes`). Uninstalling
|
||||
first would run the `[UninstallRun]` service + driver teardown between versions.
|
||||
|
||||
## Opting out of individual tasks
|
||||
|
||||
Inno's `/MERGETASKS` takes `!` prefixes to deselect a default-checked task. Use `--override`
|
||||
(replaces winget's switches) rather than `--custom` (appends — you would end up with two
|
||||
`/MERGETASKS` on one command line):
|
||||
|
||||
```powershell
|
||||
winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream"
|
||||
```
|
||||
|
||||
Task names: `installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`,
|
||||
`gamestream`, `allowpublicfw`, `startservice`, `trayicon`.
|
||||
|
||||
## Two installer behaviours that exist for this path
|
||||
|
||||
Both are in `packaging/windows/punktfunk-host.iss` and both also fix pre-existing bugs on the
|
||||
plain double-click upgrade path:
|
||||
|
||||
- **`InitializeSetup` uses `SuppressibleMsgBox`, not `MsgBox`.** A plain `MsgBox` ignores
|
||||
`/SUPPRESSMSGBOXES` and displays even under `/VERYSILENT` — an unattended install on a box that
|
||||
already runs Sunshine/Apollo would block on an invisible modal dialog. Suppressed it returns
|
||||
`IDNO`, so that install aborts (Setup exits non-zero) rather than proceeding into the unsupported
|
||||
dual-host state.
|
||||
- **`GamestreamParam` is fresh-install-only.** On an upgrade the flag is omitted entirely, which
|
||||
`service install` reads as "keep host.env as-is". Passing an explicit on/off would rewrite
|
||||
`PUNKTFUNK_HOST_CMD` whenever it still holds either canonical value — so a silent upgrade, where
|
||||
no wizard carries the old choice forward, would flip a user's GameStream setting with nothing on
|
||||
screen.
|
||||
- **`PublicFwParam` is fresh-install-only too**, and `--allow-public-network` is now tri-state
|
||||
(`=on` / `=off` / absent → keep the recorded choice, resolved from the `fw-allow-public` marker in
|
||||
`windows/service.rs`). This task is default-*unchecked*, so without the change a silent upgrade
|
||||
would have silently **revoked** a Public-network opt-in the user made once. The bare
|
||||
`--allow-public-network` form still means `on` for existing scripts; a malformed value is a hard
|
||||
error rather than a fall-through, since a typo'd opt-*out* must never resolve to "keep Public
|
||||
open".
|
||||
|
||||
## Release flow
|
||||
|
||||
`.gitea/workflows/windows-host.yml` runs on stable `v*` tags only, **after** the installer is
|
||||
attached to the Gitea release — winget validates the URL and hash, so a manifest must never be
|
||||
published ahead of its artifact:
|
||||
|
||||
```powershell
|
||||
scripts/ci/winget-manifest.ps1 -Version 0.19.2 `
|
||||
-InstallerPath C:\t\out\punktfunk-host-setup-0.19.2.exe -OutDir C:\t\out\winget
|
||||
```
|
||||
|
||||
The generated trio is attached to the same release. Canary builds are excluded: winget pins one
|
||||
immutable artifact per version, so the rolling `canary/` alias has nothing it could point at.
|
||||
|
||||
## Validating a change
|
||||
|
||||
```powershell
|
||||
winget validate --manifest packaging\winget
|
||||
winget install --manifest packaging\winget # local install from the manifest
|
||||
```
|
||||
|
||||
For a throwaway check, `winget-pkgs`' `Tools\SandboxTest.ps1` runs a manifest in Windows Sandbox.
|
||||
Note the host needs a real GPU and installs drivers, so a Sandbox run exercises the *manifest*
|
||||
(download, hash, switches, ARP correlation) rather than a working stream.
|
||||
|
||||
## Publishing
|
||||
|
||||
Through **our own REST source** on unom-1 — see [`server/`](server/README.md). It sits alongside the
|
||||
docs (3220) and flatpak (3230) services, behind the same edge Caddy; `windows-host.yml` rebuilds and
|
||||
ships its catalogue on every stable tag, so releasing is one pipeline with no manual step.
|
||||
|
||||
```powershell
|
||||
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest # elevated, once
|
||||
winget install unom.PunktfunkHost
|
||||
```
|
||||
|
||||
These manifests stay in winget-pkgs' own format rather than a bespoke one, so submitting upstream
|
||||
later is a copy, not a rewrite. Two things would need attention on that path: the signing note
|
||||
below, and `Agreements` being verified-developers-only in the community repo.
|
||||
|
||||
> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject ==
|
||||
> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and
|
||||
> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a
|
||||
> pre-existing condition rather than something winget introduces — but the community repo
|
||||
> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` /
|
||||
> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure
|
||||
> Trusted Signing is the cheap path). A self-hosted source has no such gate.
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
# Generated by build-data.mjs from the published release manifests, then rsynced to the box by
|
||||
# windows-host.yml on each stable tag. Never hand-edited, and regenerating it is one command, so
|
||||
# committing it would only produce a merge conflict on every release.
|
||||
data/
|
||||
@@ -0,0 +1,140 @@
|
||||
# Punktfunk winget REST source
|
||||
|
||||
A self-hosted [winget REST source](https://github.com/microsoft/winget-cli-restsource) serving the
|
||||
Punktfunk Windows host, so users can install and upgrade with `winget` instead of downloading a
|
||||
`setup.exe` by hand.
|
||||
|
||||
```powershell
|
||||
# one-time, from an ELEVATED prompt (winget requires admin to add a source)
|
||||
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
|
||||
|
||||
winget install unom.PunktfunkHost # silent, wizard defaults
|
||||
winget install unom.PunktfunkHost --interactive # the full wizard
|
||||
winget upgrade unom.PunktfunkHost
|
||||
```
|
||||
|
||||
## Why self-hosted rather than the community repo
|
||||
|
||||
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
|
||||
`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed
|
||||
cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen
|
||||
behaves identically whether the installer arrives by browser or by `winget` — but it does block that
|
||||
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
|
||||
`Agreements` (verified-developers-only upstream), and can serve channels the community repo would
|
||||
never accept.
|
||||
|
||||
## What it implements
|
||||
|
||||
The winget client consumes exactly three endpoints. The reference implementation's other twenty
|
||||
(`/packages/**`) are its **admin** API for mutating a CosmosDB; a catalogue generated at release
|
||||
time has nothing to mutate, so they are not implemented.
|
||||
|
||||
| Endpoint | Notes |
|
||||
| --- | --- |
|
||||
| `GET /information` | Source identity, `ServerSupportedVersions`, declared capabilities. |
|
||||
| `POST /manifestSearch` | `Query` / `Inclusions` (OR) / `Filters` (AND) / `FetchAllManifests`. **204** on no match — not an empty 200. |
|
||||
| `GET /packageManifests/{id}` | Full manifest; honours `?Version=`. Identifier match is case-insensitive. |
|
||||
| `GET /healthz` | Not part of the spec — liveness for compose. |
|
||||
|
||||
`NormalizedPackageNameAndPublisher` is deliberately declared **unsupported**. winget derives it
|
||||
client-side from ARP entries with its own normalization (case folding plus stripping legal suffixes,
|
||||
punctuation and version-like tails); claiming support would mean reimplementing that exactly, and a
|
||||
near-miss silently mis-correlates an installed host. Correlation rides on `ProductCode` instead,
|
||||
which Inno gives us exactly (`<AppId>_is1`).
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `handler.mjs` | The three endpoints. Pure `(Request, catalogue) -> Response`, no I/O, no deps. |
|
||||
| `server.mjs` | node:http front. Loads the catalogue, reloads on mtime change. |
|
||||
| `build-data.mjs` | Generates `data/data.json` from the published release manifests. Build-time only. |
|
||||
| `test.mjs` | 28 checks driving `handler.mjs` directly. |
|
||||
| `compose.production.yml` | The unom-1 service. |
|
||||
|
||||
## How it runs
|
||||
|
||||
```
|
||||
Gitea releases (manifest trio per stable tag)
|
||||
│ build-data.mjs ← walks ALL releases, no local state [CI, Linux job]
|
||||
▼
|
||||
data/data.json (~8 KB) ← rsynced to unom-1 on each stable tag
|
||||
▼
|
||||
bun + server.mjs on unom-1:3240 ← edge Caddy TLS ← winget.punktfunk.unom.io
|
||||
```
|
||||
|
||||
Stock `oven/bun` image with the two `.mjs` files bind-mounted — the same shape as the flatpak server
|
||||
(stock `caddy:2-alpine` + a bind-mounted Caddyfile). There is no image to build, publish or pull, so
|
||||
a code change deploys by scp plus `docker compose up -d`.
|
||||
|
||||
The server has **zero dependencies**: it reads a generated JSON file. Parsing YAML and talking to
|
||||
Gitea is `build-data.mjs`'s job, and that runs in CI. Only that build step needs `node_modules`.
|
||||
|
||||
**Content and config deploy separately**, matching the flatpak repo:
|
||||
|
||||
- `deploy-services.yml` (`winget` job) ships `compose.production.yml` + the two `.mjs` files.
|
||||
- `windows-host.yml` (`winget-source` job) builds and ships `data/data.json` on each stable tag.
|
||||
|
||||
`server.mjs` reloads the catalogue when its mtime changes, so publishing a release needs no restart.
|
||||
A half-written file mid-rsync keeps the previous catalogue rather than taking the source down.
|
||||
|
||||
### Why the catalogue is derived from releases
|
||||
|
||||
`build-data.mjs` walks the Gitea releases rather than reading local files. That is what makes
|
||||
`winget install --version` and `winget upgrade` work: winget resolves both against the version
|
||||
*list*, so a source that only knew the newest release could neither pin an older version nor show an
|
||||
upgrade path from one.
|
||||
|
||||
It also means the source holds no state — re-running the build reproduces the same `data.json`, so a
|
||||
lost deployment is one command away and nothing can drift.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:local # data/data.json from ../*.yaml (single version — fine for dev)
|
||||
npm test # 28 checks, no network, no server
|
||||
npm start # http://localhost:3240
|
||||
```
|
||||
|
||||
`npm test` is the gate that matters. A malformed response does not fail loudly — winget reports "no
|
||||
package found", so a shape regression looks like a missing package rather than a broken source. CI
|
||||
runs it before anything reaches the box.
|
||||
|
||||
Note a real `winget source add` will not accept a local instance: it requires HTTPS with a publicly
|
||||
trusted certificate, which is what the edge vhost provides in production.
|
||||
|
||||
## First-time setup
|
||||
|
||||
1. **DNS** — `winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone
|
||||
(DNS-only, same as `docs`). ✅ *done*
|
||||
2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block:
|
||||
|
||||
```caddyfile
|
||||
winget.punktfunk.unom.io {
|
||||
reverse_proxy 127.0.0.1:3240
|
||||
}
|
||||
```
|
||||
|
||||
Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate
|
||||
for a name it does not serve. That is the expected state, not a broken deploy.
|
||||
3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until
|
||||
a catalogue exists — expected, and visible on `/healthz`.
|
||||
4. Publish a stable tag, or build and ship the catalogue by hand:
|
||||
|
||||
```bash
|
||||
cd packaging/winget/server && npm install && npm run build && npm test
|
||||
scp data/data.json <deploy-user>@<unom-1>:~/unom-winget/data/data.json
|
||||
```
|
||||
|
||||
Then verify from anywhere:
|
||||
|
||||
```bash
|
||||
curl -s https://winget.punktfunk.unom.io/information | jq .
|
||||
curl -s -X POST https://winget.punktfunk.unom.io/manifestSearch \
|
||||
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' | jq '.Data[].PackageIdentifier'
|
||||
```
|
||||
|
||||
> Releases published before winget support shipped have no manifests attached, so `npm run build`
|
||||
> skips them. Until the first stable tag lands with them, build with `--local ..` to serve the
|
||||
> checked-in manifests, or backfill the manifest trio onto an existing release.
|
||||
@@ -0,0 +1,155 @@
|
||||
// Build src/data.json — the payload the Worker serves — from published winget manifests.
|
||||
//
|
||||
// Source of truth is the Gitea RELEASES, not local files: windows-host.yml attaches the manifest
|
||||
// trio to each stable tag, so walking releases yields every version the source should offer. That
|
||||
// matters because winget resolves `--version` and `upgrade` against the version LIST; a source that
|
||||
// only knows the newest release can neither pin an older one nor show an upgrade path from it.
|
||||
//
|
||||
// Deriving from releases also means this script holds no state. Re-running it reproduces the same
|
||||
// data.json, so a lost deployment is one command away and there is no accumulated file to drift.
|
||||
//
|
||||
// node build-data.mjs # from Gitea releases (what CI runs)
|
||||
// node build-data.mjs --local ../ # from a local manifest dir (dev, single version)
|
||||
// node build-data.mjs --out src/data.json
|
||||
//
|
||||
// The `yaml` dep is a BUILD-time dependency only — the Worker ships data.json, never a parser.
|
||||
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const API = process.env.PF_GITEA_API ?? "https://git.unom.io/api/v1";
|
||||
const REPO = process.env.PF_GITEA_REPO ?? "unom/punktfunk";
|
||||
const PACKAGE_ID = "unom.PunktfunkHost";
|
||||
const SOURCE_IDENTIFIER = process.env.PF_SOURCE_ID ?? "unom.punktfunk";
|
||||
// The API contract this source implements. Advertised via /information so a future client can
|
||||
// negotiate; bump only alongside a real change to the response shapes.
|
||||
const SERVER_SUPPORTED_VERSIONS = ["1.1.0"];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const argVal = (flag) => {
|
||||
const i = args.indexOf(flag);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
const outPath = resolve(HERE, argVal("--out") ?? "src/data.json");
|
||||
const localDir = argVal("--local");
|
||||
|
||||
/** One version's three manifests -> the shape the Worker serves. */
|
||||
function toVersionEntry(installer, locale, productCodeFallback) {
|
||||
// The REST manifest nests DefaultLocale + Installers under each version, whereas the YAML splits
|
||||
// them across files keyed by PackageVersion. Strip the per-file bookkeeping that does not belong
|
||||
// in the merged object.
|
||||
const { PackageIdentifier: _i, PackageVersion, ManifestType: _t, ManifestVersion: _v, Installers, ...installerRest } = installer;
|
||||
const { PackageIdentifier: _i2, PackageVersion: _pv, ManifestType: _t2, ManifestVersion: _v2, PackageLocale, ...localeRest } = locale;
|
||||
|
||||
// Installer-level fields are inherited by each entry in Installers unless overridden there. The
|
||||
// REST shape has no inheritance, so fold the parent down into every installer.
|
||||
const installers = (Installers ?? []).map((inst) => ({ ...installerRest, ...inst }));
|
||||
|
||||
return {
|
||||
PackageVersion,
|
||||
DefaultLocale: { PackageLocale, ...localeRest },
|
||||
Installers: installers,
|
||||
ProductCodes: [installer.ProductCode ?? productCodeFallback].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function versionsFromReleases() {
|
||||
const releases = await fetchJson(`${API}/repos/${REPO}/releases?limit=100`);
|
||||
const out = [];
|
||||
for (const rel of releases) {
|
||||
if (rel.draft) continue;
|
||||
const assets = rel.assets ?? [];
|
||||
const find = (suffix) =>
|
||||
assets.find((a) => a.name === `${PACKAGE_ID}${suffix}`)?.browser_download_url;
|
||||
const installerUrl = find(".installer.yaml");
|
||||
const localeUrl = find(".locale.en-US.yaml");
|
||||
// Releases from before winget support shipped have no manifests — skip, don't fail.
|
||||
if (!installerUrl || !localeUrl) continue;
|
||||
const [installer, locale] = await Promise.all([
|
||||
fetchText(installerUrl).then(parseYaml),
|
||||
fetchText(localeUrl).then(parseYaml),
|
||||
]);
|
||||
out.push({ entry: toVersionEntry(installer, locale), locale, prerelease: rel.prerelease });
|
||||
console.log(` + ${rel.tag_name} (${installer.PackageVersion})`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function versionsFromLocal(dir) {
|
||||
const d = resolve(HERE, dir);
|
||||
const names = readdirSync(d);
|
||||
const read = (suffix) => {
|
||||
const n = names.find((x) => x === `${PACKAGE_ID}${suffix}`);
|
||||
if (!n) throw new Error(`missing ${PACKAGE_ID}${suffix} in ${d}`);
|
||||
return parseYaml(readFileSync(join(d, n), "utf8"));
|
||||
};
|
||||
const installer = read(".installer.yaml");
|
||||
const locale = read(".locale.en-US.yaml");
|
||||
console.log(` + local (${installer.PackageVersion})`);
|
||||
return [{ entry: toVersionEntry(installer, locale), locale, prerelease: false }];
|
||||
}
|
||||
|
||||
/** Newest first, so winget's "latest" is the head of the list. */
|
||||
function compareVersionsDesc(a, b) {
|
||||
const parts = (v) => v.PackageVersion.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
|
||||
const [pa, pb] = [parts(a), parts(b)];
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const x = pa[i] ?? 0;
|
||||
const y = pb[i] ?? 0;
|
||||
if (x === y) continue;
|
||||
// A numeric segment outranks a string one (0.20.0 > 0.20.0-rc1).
|
||||
if (typeof x === typeof y) return x > y ? -1 : 1;
|
||||
return typeof x === "number" ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(localDir ? `building from local dir ${localDir}` : `building from ${API}/repos/${REPO} releases`);
|
||||
const collected = localDir ? versionsFromLocal(localDir) : await versionsFromReleases();
|
||||
if (collected.length === 0) {
|
||||
throw new Error("no winget manifests found - refusing to write an empty source");
|
||||
}
|
||||
|
||||
const versions = collected.map((c) => c.entry).sort(compareVersionsDesc);
|
||||
// Package-level search metadata comes from the NEWEST release's locale manifest, so a rename or a
|
||||
// retagged description takes effect without rewriting history.
|
||||
const newestLocale = collected.sort((a, b) => compareVersionsDesc(a.entry, b.entry))[0].locale;
|
||||
|
||||
const data = {
|
||||
sourceIdentifier: SOURCE_IDENTIFIER,
|
||||
serverSupportedVersions: SERVER_SUPPORTED_VERSIONS,
|
||||
packages: [
|
||||
{
|
||||
PackageIdentifier: PACKAGE_ID,
|
||||
PackageName: newestLocale.PackageName,
|
||||
Publisher: newestLocale.Publisher,
|
||||
Moniker: newestLocale.Moniker ?? null,
|
||||
Tags: newestLocale.Tags ?? [],
|
||||
// Union across versions: an installed 0.19.2 must still correlate after 0.20.0 ships.
|
||||
ProductCodes: [...new Set(versions.flatMap((v) => v.ProductCodes))],
|
||||
Versions: versions.map((v) => ({
|
||||
PackageVersion: v.PackageVersion,
|
||||
ProductCodes: v.ProductCodes,
|
||||
})),
|
||||
Manifest: { PackageIdentifier: PACKAGE_ID, Versions: versions },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(`wrote ${outPath} (${versions.length} version(s): ${versions.map((v) => v.PackageVersion).join(", ")})`);
|
||||
@@ -0,0 +1,41 @@
|
||||
# winget REST source for the punktfunk Windows host, on unom-1 (Hetzner Cloud).
|
||||
#
|
||||
# Caddy on that same box terminates TLS for winget.punktfunk.unom.io and reverse_proxies to :3240,
|
||||
# exactly as it already does for docs.punktfunk.unom.io -> :3220. This inner service speaks plain
|
||||
# HTTP and is not published beyond the box. `winget source add` refuses anything but HTTPS with a
|
||||
# publicly trusted certificate, so that edge vhost is a hard requirement, not decoration.
|
||||
#
|
||||
# (Note: the sibling docs/flatpak compose files still carry stale comments about a
|
||||
# `home-reverse-proxy-1` and 192.168.50.50 from an earlier home-lab topology. The public hostnames
|
||||
# resolve straight to the hcloud box and are served by Caddy there — no local proxy is involved.)
|
||||
#
|
||||
# Stock bun image + bind-mounted sources, mirroring the flatpak server's stock-caddy approach: no
|
||||
# image to build, publish or pull, so a code change deploys by scp + `docker compose up -d`.
|
||||
#
|
||||
# ./data/data.json is NOT shipped by deploy-services.yml — windows-host.yml rsyncs it on each stable
|
||||
# tag (the same split the flatpak repo uses: config deploys separately from content). server.mjs
|
||||
# reloads it on mtime change, so publishing a release needs no restart here.
|
||||
name: punktfunk-winget-prod
|
||||
services:
|
||||
winget:
|
||||
image: oven/bun:1-alpine
|
||||
restart: unless-stopped
|
||||
command: ["bun", "run", "/app/server.mjs"]
|
||||
working_dir: /app
|
||||
environment:
|
||||
PORT: "3240"
|
||||
PF_WINGET_DATA: /app/data/data.json
|
||||
ports:
|
||||
- "3240:3240"
|
||||
volumes:
|
||||
- ./server.mjs:/app/server.mjs:ro
|
||||
- ./handler.mjs:/app/handler.mjs:ro
|
||||
- ./data:/app/data:ro
|
||||
healthcheck:
|
||||
# Fails while the catalogue is missing or unparseable, which is otherwise invisible — a broken
|
||||
# source answers requests fine and just reports "no package found" to the client.
|
||||
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3240/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -0,0 +1,204 @@
|
||||
// Punktfunk's winget REST source — the read path only.
|
||||
//
|
||||
// The winget client consumes exactly three endpoints. `microsoft/winget-cli-restsource`'s other 20
|
||||
// (`/packages/**` CRUD) are that reference implementation's ADMIN API for mutating a CosmosDB —
|
||||
// they are not part of what a client calls, and a source whose data is generated at release time
|
||||
// has nothing to mutate. So this serves:
|
||||
//
|
||||
// GET /information server identity + declared capabilities
|
||||
// POST /manifestSearch search; 204 when nothing matches
|
||||
// GET /packageManifests/{PackageIdentifier} the full manifest (optional ?Version=)
|
||||
//
|
||||
// Contract: documentation/WinGet-1.1.0.yaml in that repo. Every response is wrapped in `Data`
|
||||
// (ResponseObjectSchema). Note the spec's content-type key is literally `application/Json`; the
|
||||
// client is not picky about the response's own header, so we send standard `application/json`.
|
||||
//
|
||||
// Pure request->response logic with NO I/O and no dependencies: the catalogue is passed in, so
|
||||
// server.mjs owns reloading it and test.mjs can drive this directly without a server or a network.
|
||||
// `data` is produced by build-data.mjs from the published release manifests — see the README.
|
||||
|
||||
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
||||
|
||||
const json = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
||||
|
||||
// A bare 204 carries no body — this is how the spec says "no results", distinct from an empty array.
|
||||
const noContent = () => new Response(null, { status: 204 });
|
||||
|
||||
const error = (status, message) =>
|
||||
json({ ErrorCode: status, ErrorMessage: message }, status);
|
||||
|
||||
// Match fields we deliberately do NOT claim to serve.
|
||||
//
|
||||
// `NormalizedPackageNameAndPublisher` is the notable one: winget derives it client-side from ARP
|
||||
// entries using its own normalization (case folding plus stripping legal suffixes, punctuation and
|
||||
// version-like tails). Claiming support would mean reimplementing that exactly, and a near-miss
|
||||
// produces silently wrong correlation between an installed host and this package. ProductCode is
|
||||
// exact and Inno already gives us one, so correlation rides on that instead.
|
||||
//
|
||||
// Command / PackageFamilyName are genuinely absent (no declared commands; not an MSIX). Market is
|
||||
// not modelled.
|
||||
const UNSUPPORTED_MATCH_FIELDS = [
|
||||
"Command",
|
||||
"PackageFamilyName",
|
||||
"NormalizedPackageNameAndPublisher",
|
||||
"Market",
|
||||
];
|
||||
|
||||
/** Compare one candidate string against a SearchRequestMatch. */
|
||||
function matches(value, request) {
|
||||
if (value == null || request == null) return false;
|
||||
const keyword = String(request.KeyWord ?? "");
|
||||
const hay = String(value).toLowerCase();
|
||||
const needle = keyword.toLowerCase();
|
||||
switch (request.MatchType) {
|
||||
case "Exact":
|
||||
return String(value) === keyword;
|
||||
case "StartsWith":
|
||||
return hay.startsWith(needle);
|
||||
// Fuzzy variants are served as substring rather than pretending to a real fuzzy ranker. That
|
||||
// over-matches slightly, which is the safe direction: winget re-ranks and filters what we return.
|
||||
case "Substring":
|
||||
case "Fuzzy":
|
||||
case "FuzzySubstring":
|
||||
return hay.includes(needle);
|
||||
case "Wildcard":
|
||||
return true;
|
||||
case "CaseInsensitive":
|
||||
default:
|
||||
return hay === needle;
|
||||
}
|
||||
}
|
||||
|
||||
/** The candidate strings a package offers for a given PackageMatchField. */
|
||||
function fieldValues(pkg, field) {
|
||||
switch (field) {
|
||||
case "PackageIdentifier":
|
||||
return [pkg.PackageIdentifier];
|
||||
case "PackageName":
|
||||
return [pkg.PackageName];
|
||||
case "Moniker":
|
||||
return pkg.Moniker ? [pkg.Moniker] : [];
|
||||
case "Tag":
|
||||
return pkg.Tags ?? [];
|
||||
case "ProductCode":
|
||||
return pkg.ProductCodes ?? [];
|
||||
default:
|
||||
return []; // including everything in UNSUPPORTED_MATCH_FIELDS
|
||||
}
|
||||
}
|
||||
|
||||
/** A free-text `Query` sweeps the fields a human would expect to type. */
|
||||
function matchesQuery(pkg, query) {
|
||||
if (!query) return true; // no Query block = match everything, narrowed by Inclusions/Filters
|
||||
return ["PackageIdentifier", "PackageName", "Moniker", "Tag"].some((f) =>
|
||||
fieldValues(pkg, f).some((v) => matches(v, query)),
|
||||
);
|
||||
}
|
||||
|
||||
function matchesOne(pkg, entry) {
|
||||
// Both Inclusions and Filters use SearchRequestInclusionAndFilterSchema.
|
||||
const req = entry?.RequestMatch;
|
||||
return fieldValues(pkg, entry?.PackageMatchField).some((v) => matches(v, req));
|
||||
}
|
||||
|
||||
function search(data, body) {
|
||||
const { Query, Inclusions, Filters, FetchAllManifests, MaximumResults } = body ?? {};
|
||||
|
||||
let hits = data.packages.filter((pkg) => {
|
||||
if (FetchAllManifests) return true;
|
||||
if (!matchesQuery(pkg, Query)) return false;
|
||||
// Inclusions widen (OR); an empty/absent list imposes nothing.
|
||||
if (Array.isArray(Inclusions) && Inclusions.length > 0) {
|
||||
if (!Inclusions.some((i) => matchesOne(pkg, i))) return false;
|
||||
}
|
||||
// Filters narrow (AND) — every filter must hold.
|
||||
if (Array.isArray(Filters) && Filters.length > 0) {
|
||||
if (!Filters.every((f) => matchesOne(pkg, f))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (Number.isInteger(MaximumResults) && MaximumResults > 0) {
|
||||
hits = hits.slice(0, MaximumResults);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
async function handle(request, data) {
|
||||
const url = new URL(request.url);
|
||||
// Tolerate a mount under a sub-path (e.g. /winget/information) so the same Worker can sit behind
|
||||
// a reverse proxy that does not strip a prefix.
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
const tail = (name) => path === `/${name}` || path.endsWith(`/${name}`);
|
||||
|
||||
if (tail("information") && request.method === "GET") {
|
||||
return json({
|
||||
Data: {
|
||||
SourceIdentifier: data.sourceIdentifier,
|
||||
ServerSupportedVersions: data.serverSupportedVersions,
|
||||
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
|
||||
RequiredPackageMatchFields: [],
|
||||
UnsupportedQueryParameters: [],
|
||||
RequiredQueryParameters: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (tail("manifestSearch")) {
|
||||
if (request.method !== "POST") return error(405, "manifestSearch requires POST");
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return error(400, "malformed JSON body");
|
||||
}
|
||||
const hits = search(data, body);
|
||||
if (hits.length === 0) return noContent();
|
||||
return json({
|
||||
Data: hits.map((pkg) => ({
|
||||
PackageIdentifier: pkg.PackageIdentifier,
|
||||
PackageName: pkg.PackageName,
|
||||
Publisher: pkg.Publisher,
|
||||
Versions: pkg.Versions.map((v) => ({
|
||||
PackageVersion: v.PackageVersion,
|
||||
ProductCodes: v.ProductCodes ?? [],
|
||||
})),
|
||||
})),
|
||||
RequiredPackageMatchFields: [],
|
||||
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
|
||||
});
|
||||
}
|
||||
|
||||
const manifestMatch = path.match(/\/packageManifests\/([^/]+)$/);
|
||||
if (manifestMatch) {
|
||||
if (request.method !== "GET") return error(405, "packageManifests requires GET");
|
||||
const id = decodeURIComponent(manifestMatch[1]);
|
||||
// PackageIdentifier is case-insensitive per the winget client's own handling.
|
||||
const pkg = data.packages.find(
|
||||
(p) => p.PackageIdentifier.toLowerCase() === id.toLowerCase(),
|
||||
);
|
||||
if (!pkg) return error(404, `unknown PackageIdentifier '${id}'`);
|
||||
|
||||
const wanted = url.searchParams.get("Version");
|
||||
const versions = wanted
|
||||
? pkg.Manifest.Versions.filter((v) => v.PackageVersion === wanted)
|
||||
: pkg.Manifest.Versions;
|
||||
if (versions.length === 0) return error(404, `version '${wanted}' not found`);
|
||||
|
||||
return json({ Data: { PackageIdentifier: pkg.Manifest.PackageIdentifier, Versions: versions } });
|
||||
}
|
||||
|
||||
return error(404, `no route for ${request.method} ${url.pathname}`);
|
||||
}
|
||||
|
||||
/** Wraps handle() so no request can escape as an unhandled rejection. */
|
||||
export async function respond(request, data) {
|
||||
try {
|
||||
return await handle(request, data);
|
||||
} catch (e) {
|
||||
return error(500, `unhandled: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export { handle, search, matches };
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "punktfunk-winget-source",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "punktfunk-winget-source",
|
||||
"devDependencies": {
|
||||
"yaml": "^2.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "punktfunk-winget-source",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "winget REST source for the Punktfunk Windows host (read path only).",
|
||||
"scripts": {
|
||||
"build": "node build-data.mjs --out data/data.json",
|
||||
"build:local": "node build-data.mjs --local .. --out data/data.json",
|
||||
"test": "node test.mjs",
|
||||
"start": "node server.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"yaml": "^2.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// HTTP front for the winget REST source. Runs on unom-1 under a stock `oven/bun` image with this
|
||||
// file and the data bind-mounted — same shape as the flatpak server (stock caddy:2-alpine + a
|
||||
// bind-mounted Caddyfile), so there is no image to build, publish or pull.
|
||||
//
|
||||
// Plain HTTP on :3240. Caddy on the same box terminates TLS for winget.punktfunk.unom.io and
|
||||
// reverse-proxies here — that TLS is not optional decoration: `winget source add` refuses anything
|
||||
// but HTTPS with a publicly trusted certificate.
|
||||
//
|
||||
// Zero dependencies on purpose. The catalogue is a generated JSON file; parsing YAML and talking to
|
||||
// Gitea is build-data.mjs's job, which runs in CI. Nothing here needs node_modules, so the deploy
|
||||
// is two files and `docker compose up -d`.
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer } from "node:http";
|
||||
import { respond } from "./handler.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_PATH = resolve(process.env.PF_WINGET_DATA ?? `${HERE}/data/data.json`);
|
||||
const PORT = Number(process.env.PORT ?? 3240);
|
||||
|
||||
// Reload on mtime change rather than caching for the process lifetime. Publishing a release rsyncs
|
||||
// a new data.json onto the box; without this it would serve the old catalogue until someone
|
||||
// remembered to restart the container. Stat-per-request on an ~8 KB local file is free, and it
|
||||
// keeps "deploy" and "publish" decoupled the way the flatpak repo already is.
|
||||
let cache = { mtimeMs: 0, data: null, error: null };
|
||||
|
||||
function catalogue() {
|
||||
let mtimeMs;
|
||||
try {
|
||||
mtimeMs = statSync(DATA_PATH).mtimeMs;
|
||||
} catch (e) {
|
||||
// Keep serving the last good catalogue if the file goes missing mid-flight (e.g. a partial
|
||||
// rsync) — a transient publish must not take the source down.
|
||||
if (cache.data) return cache.data;
|
||||
cache.error = `cannot stat ${DATA_PATH}: ${e.message}`;
|
||||
return null;
|
||||
}
|
||||
if (cache.data && mtimeMs === cache.mtimeMs) return cache.data;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(DATA_PATH, "utf8"));
|
||||
if (!Array.isArray(parsed?.packages)) throw new Error("no `packages` array");
|
||||
cache = { mtimeMs, data: parsed, error: null };
|
||||
console.log(`[winget] loaded ${parsed.packages.length} package(s) from ${DATA_PATH}`);
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
// A half-written file during rsync parses as garbage; prefer stale-but-valid over an outage.
|
||||
if (cache.data) {
|
||||
console.error(`[winget] reload failed, keeping previous catalogue: ${e.message}`);
|
||||
return cache.data;
|
||||
}
|
||||
cache.error = `cannot parse ${DATA_PATH}: ${e.message}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** node:http request -> WHATWG Request, so handler.mjs stays runtime-agnostic. */
|
||||
function toRequest(req) {
|
||||
const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
|
||||
const init = { method: req.method, headers: req.headers };
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
init.body = req;
|
||||
init.duplex = "half"; // required when a stream is used as a body
|
||||
}
|
||||
return new Request(url, init);
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
// Liveness for compose/monitoring — deliberately outside the winget routes so a broken catalogue
|
||||
// is visible as 503 here rather than as a confusing "no package found" in the client.
|
||||
if (req.url === "/healthz") {
|
||||
const data = catalogue();
|
||||
const ok = !!data;
|
||||
res.writeHead(ok ? 200 : 503, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(ok ? { status: "ok", packages: data.packages.length } : { status: "error", error: cache.error }));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = catalogue();
|
||||
if (!data) {
|
||||
res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ ErrorCode: 503, ErrorMessage: cache.error ?? "catalogue unavailable" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await respond(toRequest(req), data);
|
||||
} catch (e) {
|
||||
res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ ErrorCode: 500, ErrorMessage: String(e?.message ?? e) }));
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = Object.fromEntries(response.headers.entries());
|
||||
const body = response.status === 204 ? null : await response.text();
|
||||
res.writeHead(response.status, headers);
|
||||
res.end(body ?? undefined);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[winget] REST source on :${PORT}, data=${DATA_PATH}`);
|
||||
// Warm the cache at boot so a bad mount is loud immediately, not on the first user request.
|
||||
if (!catalogue()) console.error(`[winget] WARNING: ${cache.error}`);
|
||||
});
|
||||
|
||||
for (const sig of ["SIGTERM", "SIGINT"]) {
|
||||
process.on(sig, () => server.close(() => process.exit(0)));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Drives the Worker's handle() directly — no Workers runtime, no network, no deploy.
|
||||
//
|
||||
// This is the gate that catches a broken source BEFORE winget sees it. A source that returns the
|
||||
// wrong shape does not error loudly: winget reports "no package found" and the failure looks like a
|
||||
// missing package rather than a malformed response.
|
||||
//
|
||||
// node test.mjs (run `npm run build:local` first so data/data.json exists)
|
||||
import { readFileSync } from "node:fs";
|
||||
import { handle } from "./handler.mjs";
|
||||
|
||||
// The catalogue is an argument, not an import — same way server.mjs passes it — so this suite runs
|
||||
// against a generated data.json with no server, no network and no container.
|
||||
const DATA = JSON.parse(readFileSync(new URL("./data/data.json", import.meta.url), "utf8"));
|
||||
const BASE = "https://winget.example/";
|
||||
let failed = 0;
|
||||
|
||||
function check(name, cond, detail = "") {
|
||||
if (cond) {
|
||||
console.log(`ok ${name}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(`FAIL ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const get = (p) => handle(new Request(BASE + p, { method: "GET" }), DATA);
|
||||
const post = (p, body) =>
|
||||
handle(new Request(BASE + p, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }), DATA);
|
||||
|
||||
// ── /information ────────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const res = await get("information");
|
||||
const body = await res.json();
|
||||
check("information: 200", res.status === 200, `got ${res.status}`);
|
||||
check("information: wrapped in Data", !!body.Data);
|
||||
check("information: has SourceIdentifier", typeof body.Data?.SourceIdentifier === "string");
|
||||
check("information: advertises a version", Array.isArray(body.Data?.ServerSupportedVersions) && body.Data.ServerSupportedVersions.length > 0);
|
||||
check(
|
||||
"information: declares NormalizedPackageNameAndPublisher unsupported",
|
||||
body.Data?.UnsupportedPackageMatchFields?.includes("NormalizedPackageNameAndPublisher"),
|
||||
);
|
||||
}
|
||||
|
||||
// ── /manifestSearch ─────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const res = await post("manifestSearch", { Query: { KeyWord: "punktfunk", MatchType: "Substring" } });
|
||||
const body = await res.json();
|
||||
check("search substring 'punktfunk': 200", res.status === 200, `got ${res.status}`);
|
||||
check("search: Data is a non-empty array", Array.isArray(body.Data) && body.Data.length > 0);
|
||||
const hit = body.Data?.[0];
|
||||
check("search: PackageIdentifier present", hit?.PackageIdentifier === "unom.PunktfunkHost");
|
||||
check("search: PackageName + Publisher present (schema-required)", !!hit?.PackageName && !!hit?.Publisher);
|
||||
check("search: Versions non-empty (minItems 1)", Array.isArray(hit?.Versions) && hit.Versions.length > 0);
|
||||
check("search: version carries ProductCodes for ARP correlation", Array.isArray(hit?.Versions?.[0]?.ProductCodes) && hit.Versions[0].ProductCodes.length > 0);
|
||||
}
|
||||
{
|
||||
const res = await post("manifestSearch", { Query: { KeyWord: "definitely-not-a-package", MatchType: "Substring" } });
|
||||
check("search miss: 204 No Content, not an empty 200", res.status === 204, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
const res = await post("manifestSearch", { FetchAllManifests: true });
|
||||
const body = await res.json();
|
||||
check("FetchAllManifests returns everything", res.status === 200 && body.Data.length >= 1);
|
||||
}
|
||||
{
|
||||
const res = await post("manifestSearch", {
|
||||
Inclusions: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "unom.PunktfunkHost", MatchType: "CaseInsensitive" } }],
|
||||
});
|
||||
check("Inclusions on PackageIdentifier match", res.status === 200);
|
||||
}
|
||||
{
|
||||
// A filter that cannot hold must exclude the package (Filters are AND).
|
||||
const res = await post("manifestSearch", {
|
||||
Filters: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "some.OtherPackage", MatchType: "Exact" } }],
|
||||
});
|
||||
check("non-matching Filter excludes (AND semantics)", res.status === 204, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
// An unsupported field must never accidentally match — we told the client we don't serve it.
|
||||
const res = await post("manifestSearch", {
|
||||
Filters: [{ PackageMatchField: "NormalizedPackageNameAndPublisher", RequestMatch: { KeyWord: "punktfunkhostunom", MatchType: "Exact" } }],
|
||||
});
|
||||
check("unsupported match field yields no match", res.status === 204, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
const res = await handle(new Request(BASE + "manifestSearch", { method: "GET" }), DATA);
|
||||
check("manifestSearch rejects GET", res.status === 405, `got ${res.status}`);
|
||||
}
|
||||
|
||||
// ── /packageManifests/{id} ──────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const res = await get("packageManifests/unom.PunktfunkHost");
|
||||
const body = await res.json();
|
||||
check("manifest: 200", res.status === 200, `got ${res.status}`);
|
||||
const v = body.Data?.Versions?.[0];
|
||||
check("manifest: Versions present", !!v);
|
||||
check("manifest: version has DefaultLocale", !!v?.DefaultLocale?.PackageName);
|
||||
check("manifest: version has Installers", Array.isArray(v?.Installers) && v.Installers.length > 0);
|
||||
const inst = v?.Installers?.[0];
|
||||
check("manifest: installer has URL + sha256", !!inst?.InstallerUrl && /^[0-9A-F]{64}$/i.test(inst?.InstallerSha256 ?? ""));
|
||||
check("manifest: installer-level fields folded into the entry", inst?.InstallerType === "inno" && inst?.Scope === "machine");
|
||||
check("manifest: ProductCode preserved for correlation", !!inst?.ProductCode || !!v?.ProductCodes?.length);
|
||||
// A Log switch is only useful if winget SUBSTITUTES the path. <LOGPATH> is the one token it
|
||||
// replaces; anything else is passed through verbatim, and `|LOGPATH|` shipped in 0.20.0 — Inno
|
||||
// then rejected `|` as a filename and aborted with "Error creating log file", in EVERY install
|
||||
// mode, for any caller that requests a log (UniGetUI does by default). Reported from the field.
|
||||
const log = inst?.InstallerSwitches?.Log;
|
||||
check(
|
||||
"manifest: Log switch uses winget's <LOGPATH> token, if present",
|
||||
log === undefined || (log.includes("<LOGPATH>") && !/\|[A-Z]+\|/.test(log)),
|
||||
`got ${JSON.stringify(log)}`,
|
||||
);
|
||||
}
|
||||
{
|
||||
const res = await get("packageManifests/UNOM.PUNKTFUNKHOST");
|
||||
check("manifest: PackageIdentifier is case-insensitive", res.status === 200, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
const res = await get("packageManifests/nope.NotHere");
|
||||
check("manifest: unknown id -> 404", res.status === 404, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
const res = await get("packageManifests/unom.PunktfunkHost?Version=0.0.0-nope");
|
||||
check("manifest: unknown ?Version -> 404", res.status === 404, `got ${res.status}`);
|
||||
}
|
||||
{
|
||||
const res = await get("no/such/route");
|
||||
check("unknown route -> 404", res.status === 404, `got ${res.status}`);
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? "\nall checks passed" : `\n${failed} CHECK(S) FAILED`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,63 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json
|
||||
#
|
||||
# Installer manifest for the Windows host (packaging/windows/punktfunk-host.iss -> Inno Setup 6).
|
||||
#
|
||||
# The host is a machine-wide, elevated install: it registers a SYSTEM service, installs the
|
||||
# pf-vdisplay + gamepad drivers, and opens firewall ports. There is no per-user scope.
|
||||
PackageIdentifier: unom.PunktfunkHost
|
||||
PackageVersion: 0.19.2
|
||||
|
||||
InstallerType: inno
|
||||
Scope: machine
|
||||
# PrivilegesRequired=admin in the .iss -> Setup raises its own UAC prompt.
|
||||
ElevationRequirement: elevatesSelf
|
||||
# Windows 11 22H2 floor: pf-vdisplay is built against IddCx 1.10 with no runtime downgrade, so on
|
||||
# anything older the device fails to start with Code 10 (see the MinVersion gate in the .iss).
|
||||
MinimumOSVersion: 10.0.22621.0
|
||||
|
||||
InstallModes:
|
||||
# interactive keeps the FULL wizard — every task checkbox, the web-console password page, and the
|
||||
# VB-CABLE notice text. `winget install unom.PunktfunkHost --interactive`.
|
||||
- interactive
|
||||
- silent
|
||||
- silentWithProgress
|
||||
|
||||
InstallerSwitches:
|
||||
# Spelled out rather than relying on winget's built-in `inno` defaults, so the unattended
|
||||
# behaviour is reviewable here. No /MERGETASKS: a silent install deliberately takes the SAME
|
||||
# task defaults the wizard shows, so the product does not differ by install channel. The
|
||||
# disclosures the wizard puts on screen are carried by Agreements/InstallationNotes in the
|
||||
# locale manifest instead.
|
||||
#
|
||||
# To CHANGE individual tasks, callers pass Inno's /MERGETASKS via --override: a bare name adds a
|
||||
# task, a `!` prefix removes one. GameStream is opt-in (its plane pairs over plain HTTP), so
|
||||
# enabling it unattended is the additive form:
|
||||
# winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=gamestream"
|
||||
# and dropping a default-on task is the negated form, e.g. /MERGETASKS=!trayicon
|
||||
# Task names: installdriver, installgamepad, installaudiocable, installhdrlayer,
|
||||
# gamestream, allowpublicfw, startservice, trayicon
|
||||
Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
# <LOGPATH> is winget's OWN token and the only spelling it substitutes (see the schema this file
|
||||
# declares: "…<LOGPATH> token can be included in the switch value so that winget will replace the
|
||||
# token with user provided path"). It was written |LOGPATH| — not a token, so winget passed the
|
||||
# literal string through and Inno rejected it: `|` is not legal in a Windows filename, giving
|
||||
# "Error creating log file: The filename, directory name, or volume label syntax is incorrect."
|
||||
# That aborted the install in EVERY mode, interactive included, for any caller that asks for a
|
||||
# log — UniGetUI does so by default, which is how it was reported.
|
||||
Log: /LOG="<LOGPATH>"
|
||||
|
||||
# Inno writes its ARP entry under <AppId>_is1; this is what correlates an installed host with the
|
||||
# package for `winget list` / `winget upgrade`. Must track AppId in the .iss.
|
||||
ProductCode: '{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}_is1'
|
||||
# Inno upgrades in place (UsePreviousAppDir=yes) — do not uninstall first, that would run the
|
||||
# [UninstallRun] service/driver teardown between versions.
|
||||
UpgradeBehavior: install
|
||||
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerUrl: https://git.unom.io/unom/punktfunk/releases/download/v0.19.2/punktfunk-host-setup-0.19.2.exe
|
||||
InstallerSha256: 96964117125BFD5AC4556987DAFAA3966A4A57BBC29C838803857852D595BF4D
|
||||
|
||||
ManifestType: installer
|
||||
ManifestVersion: 1.6.0
|
||||
@@ -0,0 +1,73 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json
|
||||
PackageIdentifier: unom.PunktfunkHost
|
||||
PackageVersion: 0.19.2
|
||||
PackageLocale: en-US
|
||||
|
||||
Publisher: unom
|
||||
PublisherUrl: https://git.unom.io/unom/punktfunk
|
||||
PublisherSupportUrl: https://git.unom.io/unom/punktfunk/issues
|
||||
PackageName: Punktfunk Host
|
||||
PackageUrl: https://docs.punktfunk.unom.io/docs/windows-host
|
||||
License: MIT OR Apache-2.0
|
||||
LicenseUrl: https://git.unom.io/unom/punktfunk/src/branch/main/LICENSE-MIT
|
||||
Copyright: Copyright (c) unom
|
||||
ShortDescription: Low-latency game-streaming host — stream your PC's games to any Punktfunk or Moonlight client.
|
||||
Description: |-
|
||||
Punktfunk Host turns a Windows 11 PC into a low-latency game-streaming server. It captures the
|
||||
desktop or a dedicated virtual display, encodes with NVENC / AMF / QSV / Vulkan, and streams over
|
||||
its native QUIC protocol to Punktfunk clients on Windows, macOS, iOS, tvOS, Android and Linux —
|
||||
or over GameStream to stock Moonlight clients.
|
||||
|
||||
The installer bundles the pf-vdisplay virtual display driver (native-resolution and HDR streaming
|
||||
without a physical monitor), virtual DualSense / DualShock 4 / Xbox 360 gamepads, and a web
|
||||
management console served on port 47992.
|
||||
Moniker: punktfunk-host
|
||||
Tags:
|
||||
- game-streaming
|
||||
- remote-desktop
|
||||
- moonlight
|
||||
- gamestream
|
||||
- streaming
|
||||
- remote-play
|
||||
ReleaseNotesUrl: https://git.unom.io/unom/punktfunk/src/branch/main/docs/releases/v0.19.2.md
|
||||
Documentations:
|
||||
- DocumentLabel: Windows Host Setup
|
||||
DocumentUrl: https://docs.punktfunk.unom.io/docs/windows-host
|
||||
|
||||
# Shown BEFORE download/install; the user must accept or the install does not proceed. This is what
|
||||
# carries — on the unattended path, where no wizard page is on screen — the disclosures the wizard
|
||||
# puts in its task text. VB-Audio's bundling grant specifically requires the end user to see
|
||||
# VB-CABLE's origin + donationware status at install time.
|
||||
Agreements:
|
||||
- AgreementLabel: Bundled virtual audio (VB-CABLE by VB-Audio)
|
||||
Agreement: >-
|
||||
Punktfunk's streaming microphone uses VB-CABLE by VB-Audio (www.vb-cable.com), which this
|
||||
installer bundles and installs. VB-CABLE is donationware — all participations welcome. It is
|
||||
redistributed under VB-Audio's bundling grant; the full notice is installed to
|
||||
%ProgramFiles%\punktfunk\licenses\VB-CABLE-NOTICE.txt.
|
||||
AgreementUrl: https://vb-audio.com/Cable/
|
||||
- AgreementLabel: GameStream (Moonlight) compatibility is OFF by default
|
||||
Agreement: >-
|
||||
Punktfunk's own clients work out of the box. Support for stock Moonlight clients is a separate,
|
||||
opt-in plane, because it pairs over legacy plain HTTP and is intended for trusted LANs only. To
|
||||
install WITH it, use --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
/MERGETASKS=gamestream", or run --interactive and tick the GameStream checkbox. It can also be
|
||||
turned on later with `punktfunk-host service install --gamestream=on` (or via
|
||||
PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env), followed by a service restart.
|
||||
AgreementUrl: https://docs.punktfunk.unom.io/docs/windows-host
|
||||
|
||||
# Displayed after the install completes. The console password is generated per install, so it can
|
||||
# only be pointed at, never printed here — this text is fixed at publish time.
|
||||
InstallationNotes: |-
|
||||
Web management console: https://<this-PC>:47992
|
||||
The login password was generated during installation and is stored in
|
||||
%ProgramData%\punktfunk\web-password (edit that file to change it).
|
||||
|
||||
Configuration: %ProgramData%\punktfunk\host.env
|
||||
Logs: %ProgramData%\punktfunk\logs\
|
||||
|
||||
The host service starts automatically at boot. `punktfunk-host` is on the machine PATH — run
|
||||
`punktfunk-host service status` from an elevated prompt to check it.
|
||||
|
||||
ManifestType: defaultLocale
|
||||
ManifestVersion: 1.6.0
|
||||
@@ -0,0 +1,10 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json
|
||||
#
|
||||
# Version manifest — the pointer that ties the installer + locale manifests together.
|
||||
# Generated per stable tag by scripts/ci/winget-manifest.ps1; this checked-in copy is the
|
||||
# template + the reference for what a release looks like. Validate with `winget validate`.
|
||||
PackageIdentifier: unom.PunktfunkHost
|
||||
PackageVersion: 0.19.2
|
||||
DefaultLocale: en-US
|
||||
ManifestType: version
|
||||
ManifestVersion: 1.6.0
|
||||
@@ -21,6 +21,7 @@ export { type CacheStore, makeCacheStore } from "./cache-store.js";
|
||||
export {
|
||||
Artwork,
|
||||
DetectHint,
|
||||
GameMeta,
|
||||
LaunchSpec,
|
||||
PrepStep,
|
||||
ProviderClient,
|
||||
|
||||
@@ -46,6 +46,29 @@ export const DetectHint = Schema.Struct({
|
||||
});
|
||||
export type DetectHint = typeof DetectHint.Type;
|
||||
|
||||
/** Descriptive metadata, flat on the wire beside `title` (mirrors the host's flattened
|
||||
* `GameMeta`). All fields optional; values are free-form display strings — the host does not
|
||||
* normalize platform/genre vocabularies. */
|
||||
export const GameMeta = Schema.Struct({
|
||||
/** The system the title runs on — `"PS2"`, `"Xbox 360"`, `"SNES"`, … */
|
||||
platform: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
/** Short blurb for a details pane. */
|
||||
description: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
developer: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
publisher: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
/** Year of first release. */
|
||||
release_year: Schema.optionalKey(Schema.NullOr(Schema.Number)),
|
||||
/** Genre taxonomy from the metadata source (`"RPG"`, `"Platformer"`, …). */
|
||||
genres: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
/** Free-form organizational labels (`"co-op"`, `"kids"`, …). */
|
||||
tags: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
/** Release region — `"NTSC-U"`, `"PAL"`, `"NTSC-J"`. */
|
||||
region: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
||||
/** Maximum simultaneous (local) players. */
|
||||
players: Schema.optionalKey(Schema.NullOr(Schema.Number)),
|
||||
});
|
||||
export type GameMeta = typeof GameMeta.Type;
|
||||
|
||||
export const ProviderEntry = Schema.Struct({
|
||||
external_id: Schema.String,
|
||||
title: Schema.String,
|
||||
@@ -53,5 +76,6 @@ export const ProviderEntry = Schema.Struct({
|
||||
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
|
||||
prep: Schema.optionalKey(Schema.Array(PrepStep)),
|
||||
detect: Schema.optionalKey(DetectHint),
|
||||
...GameMeta.fields,
|
||||
});
|
||||
export type ProviderEntry = typeof ProviderEntry.Type;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Emit the winget manifest trio for a released Windows host installer.
|
||||
#
|
||||
# winget pins the installer by SHA256, so a manifest is only ever valid for ONE immutable artifact.
|
||||
# The `latest/` channel alias in the generic registry is therefore NOT usable here — this always
|
||||
# writes the versioned Gitea release URL, which the registry makes immutable (a re-upload 409s).
|
||||
#
|
||||
# The three files are templated from packaging/winget/, which holds the reviewed, hand-maintained
|
||||
# copy: everything except PackageVersion / InstallerUrl / InstallerSha256 is edited THERE, not here.
|
||||
# That keeps the switches, agreements and installation notes under normal code review rather than
|
||||
# buried in a generator.
|
||||
#
|
||||
# Usage (from windows-host.yml, stable tags only):
|
||||
# & scripts/ci/winget-manifest.ps1 -Version 0.19.2 -InstallerPath C:\t\out\punktfunk-host-setup-0.19.2.exe -OutDir C:\t\out\winget
|
||||
#
|
||||
# Emits: <OutDir>/unom.PunktfunkHost.yaml
|
||||
# <OutDir>/unom.PunktfunkHost.installer.yaml
|
||||
# <OutDir>/unom.PunktfunkHost.locale.en-US.yaml
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Version,
|
||||
# The packed setup.exe — hashed here so the manifest can never disagree with what shipped.
|
||||
[Parameter(Mandatory = $true)][string]$InstallerPath,
|
||||
[Parameter(Mandatory = $true)][string]$OutDir,
|
||||
[string]$TemplateDir = (Join-Path $PSScriptRoot '..\..\packaging\winget'),
|
||||
# Overridable so a fork/mirror can retarget without editing the templates.
|
||||
[string]$UrlBase = 'https://git.unom.io/unom/punktfunk/releases/download'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $InstallerPath)) { throw "installer not found: $InstallerPath" }
|
||||
$TemplateDir = (Resolve-Path $TemplateDir).Path
|
||||
|
||||
# winget's own tooling emits UPPERCASE hex; match it so a manifest diff against a wingetcreate-
|
||||
# generated one is empty rather than a case-only churn.
|
||||
$sha = (Get-FileHash -Path $InstallerPath -Algorithm SHA256).Hash.ToUpperInvariant()
|
||||
$url = "$UrlBase/v$Version/$(Split-Path $InstallerPath -Leaf)"
|
||||
Write-Host "winget manifest: version=$Version sha256=$sha"
|
||||
Write-Host " url=$url"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
# PackageVersion appears in all three files; InstallerUrl/Sha256 only in the installer manifest.
|
||||
# Anchored replacements (line-leading key) so a version string inside prose — e.g. the release-notes
|
||||
# URL in the locale manifest — is never rewritten by accident.
|
||||
$files = @(
|
||||
'unom.PunktfunkHost.yaml',
|
||||
'unom.PunktfunkHost.installer.yaml',
|
||||
'unom.PunktfunkHost.locale.en-US.yaml'
|
||||
)
|
||||
foreach ($f in $files) {
|
||||
$src = Join-Path $TemplateDir $f
|
||||
if (-not (Test-Path $src)) { throw "template missing: $src" }
|
||||
$text = Get-Content -Path $src -Raw
|
||||
|
||||
$text = [regex]::Replace($text, '(?m)^PackageVersion:.*$', "PackageVersion: $Version")
|
||||
$text = [regex]::Replace($text, '(?m)^(\s*)InstallerUrl:.*$', "`${1}InstallerUrl: $url")
|
||||
$text = [regex]::Replace($text, '(?m)^(\s*)InstallerSha256:.*$', "`${1}InstallerSha256: $sha")
|
||||
# The release-notes link is per-version and lives outside the anchored keys above.
|
||||
$text = [regex]::Replace($text, '(?m)^ReleaseNotesUrl:.*$',
|
||||
"ReleaseNotesUrl: https://git.unom.io/unom/punktfunk/src/tag/v$Version/docs/releases/v$Version.md")
|
||||
|
||||
$dest = Join-Path $OutDir $f
|
||||
# UTF-8 *without* BOM: winget's YAML parser rejects a BOM'd manifest.
|
||||
[IO.File]::WriteAllText($dest, $text, (New-Object Text.UTF8Encoding $false))
|
||||
Write-Host " wrote $dest"
|
||||
}
|
||||
|
||||
# Fail loudly if a template ever loses one of the fields we rewrite — a silently un-substituted
|
||||
# manifest would publish the PREVIOUS release's hash, which winget would then refuse to install.
|
||||
$inst = Get-Content -Path (Join-Path $OutDir 'unom.PunktfunkHost.installer.yaml') -Raw
|
||||
foreach ($needle in @($Version, $sha, $url)) {
|
||||
if ($inst -notmatch [regex]::Escape($needle)) { throw "substitution failed - '$needle' missing from the installer manifest" }
|
||||
}
|
||||
Write-Host "winget manifests ready in $OutDir"
|
||||
+17
-1
@@ -32,8 +32,24 @@ case "${1-}" in
|
||||
systemctl reset-failed "$dm" 2>/dev/null || :
|
||||
exec systemctl restart "$dm"
|
||||
;;
|
||||
linger)
|
||||
# Keep the caller's own `systemd --user` manager alive across the stop. Stopping the display
|
||||
# manager ends the user's last login session, and logind then stops user@<uid>.service after
|
||||
# UserStopDelaySec (10s by default) — which takes the host that asked for the takeover with
|
||||
# it, so nothing is left to restart the display manager and the box stays dark. Lingering is
|
||||
# what breaks that dependency (the setup docs already ask for it).
|
||||
#
|
||||
# The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller,
|
||||
# so this grant enables lingering for that caller alone.
|
||||
uid=${PKEXEC_UID:-}
|
||||
[ -n "$uid" ] || {
|
||||
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2
|
||||
exit 1
|
||||
}
|
||||
exec loginctl enable-linger "$uid"
|
||||
;;
|
||||
*)
|
||||
echo "usage: pf-dm-helper stop|restore" >&2
|
||||
echo "usage: pf-dm-helper stop|restore|linger" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -33,11 +33,15 @@ PUNKTFUNK_SECURE_DDA=1
|
||||
# Log level (info | debug | trace). Logs land in %ProgramData%\punktfunk\logs\.
|
||||
RUST_LOG=info
|
||||
|
||||
# The host subcommand the service launches. Default: `serve --gamestream` (native punktfunk/1 host
|
||||
# ALWAYS on + the GameStream/Moonlight-compat planes). Use `serve` for a SECURE native-only host
|
||||
# (no plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9). The installer's
|
||||
# "Enable GameStream (Moonlight) compatibility" task sets this; a custom value you write here is
|
||||
# never overwritten by a reinstall/upgrade.
|
||||
# The host subcommand the service launches. The native punktfunk/1 host is ALWAYS on; `--gamestream`
|
||||
# adds the GameStream/Moonlight-compat planes, which pair over plain HTTP and reuse legacy GCM
|
||||
# nonces (security-review #5/#9) — so `serve` alone is the SECURE native-only host.
|
||||
#
|
||||
# A Windows install writes this line explicitly from the installer's "Enable GameStream (Moonlight)
|
||||
# compatibility" task, which is OPT-IN (unchecked) — so a default install lands `serve`. Only if
|
||||
# this line is absent altogether does the service fall back to its built-in `serve --gamestream`.
|
||||
# Change it later with `punktfunk-host service install --gamestream=on|off` + a service restart; a
|
||||
# custom value you write here by hand is never overwritten by a reinstall/upgrade.
|
||||
#PUNKTFUNK_HOST_CMD=serve --gamestream
|
||||
|
||||
# Multi-GPU boxes only: force the NVENC/Desktop-Duplication GPU by Description substring. Leave
|
||||
|
||||
+15
-14
@@ -135,10 +135,10 @@ export type RuntimeStatus = { readonly "active_sessions": number, readonly "audi
|
||||
export const RuntimeStatus = Schema.Struct({ "active_sessions": Schema.Number.annotate({ "description": "Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The\nnative server admits concurrent sessions, so this can exceed 1; `session`/`stream` below\ndescribe a single representative session for the detail card.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "audio_streaming": Schema.Boolean.annotate({ "description": "True while the audio stream thread is running." }), "games": Schema.Array(ActiveGame).annotate({ "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." }), "paired_clients": Schema.Number.annotate({ "description": "Number of pinned (paired) client certificates.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "pin_pending": Schema.Boolean.annotate({ "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." }), "session": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "A representative active session. GameStream's launch (Moonlight `/launch`) when present, else\nthe first live native session. `null` when nothing is streaming." })], { mode: "oneOf" })), "stream": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "bitrate_kbps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "codec": ApiCodec, "fps": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "height": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "last_resize_ms": Schema.optionalKey(Schema.Never), "min_fec": Schema.Number.annotate({ "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "packet_size": Schema.Number.annotate({ "description": "Video payload size per packet (bytes).", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "time_to_first_frame_ms": Schema.optionalKey(Schema.Never), "width": Schema.Number.annotate({ "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) }).annotate({ "description": "The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's\nmode/codec/bitrate. `null` when nothing is streaming." })], { mode: "oneOf" })), "video_streaming": Schema.Boolean.annotate({ "description": "True while the video stream thread is running." }) }).annotate({ "description": "Live host status (changes as clients launch/end sessions)." })
|
||||
export type DisplayStateResponse = { readonly "displays": ReadonlyArray<ApiDisplayInfo> }
|
||||
export const DisplayStateResponse = Schema.Struct({ "displays": Schema.Array(ApiDisplayInfo) }).annotate({ "description": "The host's managed virtual displays right now." })
|
||||
export type GpuState = { readonly "active"?: null | { readonly "backend": string, readonly "id": string, readonly "name": string, readonly "sessions": number, readonly "vendor": string }, readonly "env_override"?: string | null, readonly "gpus": ReadonlyArray<ApiGpu>, readonly "mode": string, readonly "preferred_available": boolean, readonly "preferred_id"?: string | null, readonly "preferred_name"?: string | null, readonly "selected"?: null | { readonly "id": string, readonly "name": string, readonly "source": string, readonly "vendor": string } }
|
||||
export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." })
|
||||
export type GameEntry = { readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string }
|
||||
export const GameEntry = Schema.Struct({ "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "One title in the unified library, regardless of which store it came from." })
|
||||
export type GpuState = { readonly "active"?: null | { readonly "backend": string, readonly "id": string, readonly "name": string, readonly "sessions": number, readonly "vendor": string }, readonly "encoder_pin"?: string | null, readonly "env_override"?: string | null, readonly "gpus": ReadonlyArray<ApiGpu>, readonly "mode": string, readonly "preferred_available": boolean, readonly "preferred_id"?: string | null, readonly "preferred_name"?: string | null, readonly "selected"?: null | { readonly "id": string, readonly "name": string, readonly "source": string, readonly "vendor": string } }
|
||||
export const GpuState = Schema.Struct({ "active": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "backend": Schema.String.annotate({ "description": "The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`)." }), "id": Schema.String.annotate({ "description": "Stable id matching an entry of `gpus` (empty for the CPU/software encoder)." }), "name": Schema.String, "sessions": Schema.Number.annotate({ "description": "Number of live encode sessions on it.", "format": "int32" }).check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU live sessions use right now (absent while nothing is streaming)." })], { mode: "oneOf" })), "encoder_pin": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_ENCODER` (the host.env encoder pin), when set to something other than `auto`\n(e.g. `qsv`, `nvenc`, `amf`, `software`). A pin whose vendor contradicts the selected\nGPU is overridden at session open — the adapter wins — so the console can warn that the\npin is stale rather than letting the selection look broken." })), "env_override": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "`PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is\n`auto`; a manual preference overrides it." })), "gpus": Schema.Array(ApiGpu).annotate({ "description": "The host's hardware GPUs." }), "mode": Schema.String.annotate({ "description": "`auto` or `manual`." }), "preferred_available": Schema.Boolean.annotate({ "description": "Whether the preferred GPU is currently present." }), "preferred_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so\na console can offer returning to it). May reference a GPU that is currently absent." })), "preferred_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The stored name of the preferred GPU (a usable label even when it is absent)." })), "selected": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "id": Schema.String, "name": Schema.String, "source": Schema.String.annotate({ "description": "Why this GPU was selected: `preference` (the manual choice), `env`\n(`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or\n`preference_missing` (a manual choice is set but that GPU is absent — auto-selected\ninstead so the host keeps streaming)." }), "vendor": Schema.String.annotate({ "description": "`nvidia` | `amd` | `intel` | `other`." }) }).annotate({ "description": "The GPU the next session will use." })], { mode: "oneOf" })) }).annotate({ "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now." })
|
||||
export type GameEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art": Artwork, readonly "id": string, readonly "launch"?: null | { readonly "kind": string, readonly "value": string }, readonly "provider"?: string | null, readonly "store": string, readonly "title": string }
|
||||
export const GameEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Artwork, "id": Schema.String.annotate({ "description": "Stable, store-qualified id: `steam:<appid>` or `custom:<id>`." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, Schema.Struct({ "kind": Schema.String.annotate({ "description": "`\"steam_appid\"` or `\"command\"`." }), "value": Schema.String.annotate({ "description": "The appid (for `steam_appid`) or the shell command (for `command`)." }) }).annotate({ "description": "How the host would launch it, when known." })], { mode: "oneOf" })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." })), "store": Schema.String.annotate({ "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`." }), "title": Schema.String }).annotate({ "description": "Descriptive metadata, flattened — see [`GameMeta`]." })
|
||||
export type HooksConfig = { readonly "hooks"?: ReadonlyArray<HookEntry> }
|
||||
export const HooksConfig = Schema.Struct({ "hooks": Schema.optionalKey(Schema.Array(HookEntry)) }).annotate({ "description": "The operator's hook configuration — the `hooks.json` document and the `/api/v1/hooks` body." })
|
||||
export type LogPage = { readonly "dropped": boolean, readonly "entries": ReadonlyArray<LogEntry>, readonly "next": number }
|
||||
@@ -159,12 +159,12 @@ export type DisplayLayoutRequest = { readonly "positions"?: { readonly [x: strin
|
||||
export const DisplayLayoutRequest = Schema.Struct({ "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).annotate({ "description": "`{\"<identity_slot>\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits." }).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`)." })
|
||||
export type Layout = { readonly "mode"?: LayoutMode, readonly "positions"?: { readonly [x: string]: Position } }
|
||||
export const Layout = Schema.Struct({ "mode": Schema.optionalKey(LayoutMode), "positions": Schema.optionalKey(Schema.Record(Schema.String, Position).check(Schema.isPropertyNames(Schema.String))) }).annotate({ "description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON)." })
|
||||
export type CustomEntry = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "provider"?: string | null, readonly "title": string }
|
||||
export const CustomEntry = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "title": Schema.String }).annotate({ "description": "A user-added title, persisted in the hardened host config dir's `library.json` (see\n[`custom_path`]). Same shape the API returns and the web console edits." })
|
||||
export type CustomInput = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
|
||||
export const CustomInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Request body to create or replace a custom entry (no `id` — the host owns it)." })
|
||||
export type ProviderEntryInput = { readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
|
||||
export const ProviderEntryInput = Schema.Struct({ "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key." })
|
||||
export type CustomEntry = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id"?: string | null, readonly "id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "provider"?: string | null, readonly "title": string }
|
||||
export const CustomEntry = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process once it is running (design §9) — the one thing a\nprovider knows that the host cannot work out for itself.\n\nOptional: without it the entry is still tracked by the child the host spawns for it, which\ncovers every command that stays in the foreground. It earns its keep for a command that hands\noff and exits — a launcher script, a `flatpak run`, a front-end that starts an emulator — where\nthe host would otherwise lose the game the moment the shim returns." })), "external_id": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." })), "id": Schema.String.annotate({ "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." })), "provider": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." })
|
||||
export type CustomInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
|
||||
export const CustomInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]." })), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]. Replaced\nwholesale on update, like `art`: an edit must round-trip every field it wants kept." })
|
||||
export type ProviderEntryInput = { readonly "description"?: string | null, readonly "developer"?: string | null, readonly "genres"?: ReadonlyArray<string>, readonly "platform"?: string | null, readonly "players"?: never, readonly "publisher"?: string | null, readonly "region"?: string | null, readonly "release_year"?: never, readonly "tags"?: ReadonlyArray<string>, readonly "art"?: Artwork, readonly "detect"?: { readonly "exe"?: string | null, readonly "install_dir"?: string | null, readonly "process_name"?: string | null }, readonly "external_id": string, readonly "launch"?: null | LaunchSpec, readonly "prep"?: ReadonlyArray<PrepCmd>, readonly "title": string }
|
||||
export const ProviderEntryInput = Schema.Struct({ "description": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Short blurb for a details pane." })), "developer": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "genres": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Genre taxonomy from the metadata source (`\"RPG\"`, `\"Platformer\"`, …)." })), "platform": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The system the title runs on — `\"PS2\"`, `\"Xbox 360\"`, `\"SNES\"`, … Installed-store\nscanners stamp `\"PC\"`; `GET /library?platform=` filters on it (case-insensitive)." })), "players": Schema.optionalKey(Schema.Never), "publisher": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), "region": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Release region — emulation-relevant (`\"NTSC-U\"`, `\"PAL\"`, `\"NTSC-J\"`)." })), "release_year": Schema.optionalKey(Schema.Never), "tags": Schema.optionalKey(Schema.Array(Schema.String).annotate({ "description": "Free-form organizational labels (`\"co-op\"`, `\"kids\"`, `\"finished\"`, …)." })), "art": Schema.optionalKey(Artwork), "detect": Schema.optionalKey(Schema.Struct({ "exe": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The game's own executable, as an absolute path." })), "install_dir": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "Where the title is installed. Any process running from under this directory is part of the\ngame — the universal recipe, and the one worth supplying if you supply only one." })), "process_name": Schema.optionalKey(Schema.Union([Schema.String, Schema.Null]).annotate({ "description": "The executable's file name (`Hades.exe`), when its location isn't fixed. Weakest of the three\n— see [`DetectSpec::process_name`]." })) }).annotate({ "description": "How to recognize this title's process — see [`CustomEntry::detect`]. A provider that knows its\ntitles' install directories (Playnite does) should send them: it is what lets a game launched\nthrough the provider's own client still end its session when the player quits." })), "external_id": Schema.String.annotate({ "description": "The provider's stable id for this title (the reconcile diff key)." }), "launch": Schema.optionalKey(Schema.Union([Schema.Null, LaunchSpec], { mode: "oneOf" })), "prep": Schema.optionalKey(Schema.Array(PrepCmd).annotate({ "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." })), "title": Schema.String }).annotate({ "description": "Descriptive metadata (platform, description, …), flattened — see [`GameMeta`]." })
|
||||
export type CatalogResponse = { readonly "busy": boolean, readonly "host": HostFacts, readonly "plugins": ReadonlyArray<CatalogEntry>, readonly "sources": ReadonlyArray<SourceView> }
|
||||
export const CatalogResponse = Schema.Struct({ "busy": Schema.Boolean.annotate({ "description": "True while a package operation is in flight — the console disables install buttons." }), "host": HostFacts, "plugins": Schema.Array(CatalogEntry), "sources": Schema.Array(SourceView) })
|
||||
export type StatsSample = { readonly "bitrate_kbps": number, readonly "fec_recovered": number, readonly "fps": number, readonly "frames_dropped": number, readonly "mbps": number, readonly "packets_dropped": number, readonly "repeat_fps": number, readonly "send_dropped": number, readonly "session_id": number, readonly "stages": ReadonlyArray<StageTiming>, readonly "t_ms": number }
|
||||
@@ -316,8 +316,8 @@ export type GetHostInfo200 = HostInfo
|
||||
export const GetHostInfo200 = HostInfo
|
||||
export type GetHostInfo401 = ApiError
|
||||
export const GetHostInfo401 = ApiError
|
||||
export type GetLibraryParams = { readonly "provider"?: string }
|
||||
export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String) })
|
||||
export type GetLibraryParams = { readonly "provider"?: string, readonly "platform"?: string }
|
||||
export const GetLibraryParams = Schema.Struct({ "provider": Schema.optionalKey(Schema.String), "platform": Schema.optionalKey(Schema.String) })
|
||||
export type GetLibrary200 = ReadonlyArray<GameEntry>
|
||||
export const GetLibrary200 = Schema.Array(GameEntry)
|
||||
export type GetLibrary401 = ApiError
|
||||
@@ -893,7 +893,7 @@ export const make = (
|
||||
}))
|
||||
),
|
||||
"getLibrary": (options) => HttpClientRequest.get(`/api/v1/library`).pipe(
|
||||
HttpClientRequest.setUrlParams({ "provider": options?.params?.["provider"] as any }),
|
||||
HttpClientRequest.setUrlParams({ "provider": options?.params?.["provider"] as any, "platform": options?.params?.["platform"] as any }),
|
||||
withResponse(options?.config)(HttpClientResponse.matchStatus({
|
||||
"2xx": decodeSuccess(GetLibrary200),
|
||||
"401": decodeError("GetLibrary401", GetLibrary401),
|
||||
@@ -1447,7 +1447,8 @@ readonly "getHostInfo": <Config extends OperationConfig>(options: { readonly con
|
||||
* Every installed-store title (Steam, read from the host's local files — no Steam API key)
|
||||
* merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
|
||||
* fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the
|
||||
* entries a given external provider owns.
|
||||
* entries a given external provider owns; `?platform=` to one platform (case-insensitive —
|
||||
* installed-store titles are `PC`, custom/provider entries carry whatever was authored).
|
||||
*/
|
||||
readonly "getLibrary": <Config extends OperationConfig>(options: { readonly params?: typeof GetLibraryParams.Encoded | undefined; readonly config?: Config | undefined } | undefined) => Effect.Effect<WithOptionalResponse<typeof GetLibrary200.Type, Config>, HttpClientError.HttpClientError | SchemaError | PunktfunkError<"GetLibrary401", typeof GetLibrary401.Type>>
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "display-disturb"
|
||||
description = "Deterministic display-stack disturbance generator (vdisplay stall-immunity bench)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Devices_Display",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
@@ -0,0 +1,332 @@
|
||||
//! `display-disturb` — deterministic display-stack disturbance generator (Windows).
|
||||
//!
|
||||
//! The vdisplay stall-immunity bench (design: punktfunk-planning
|
||||
//! `design/vdisplay-disturbance-immunity.md`) needs both stall classes reproducible on demand,
|
||||
//! without waiting for a standby TV or a monitor-tool storm:
|
||||
//!
|
||||
//! * `ddc` — Class 2: DDC/CI traffic through the win32k → dxgkrnl → miniport I2C path, exactly
|
||||
//! what Twinkle-Tray/PowerDisplay-class tools emit after every HPD blip (one VCP read ≈ 100 ms,
|
||||
//! a capabilities string up to ~1 s — serialized per physical I2C bus). Requires a physical
|
||||
//! monitor; virtual displays expose no DDC handle.
|
||||
//! * `modeset` — Class 1: a same-mode `ChangeDisplaySettingsExW(CDS_RESET)` re-commit — a
|
||||
//! Level-Two modeset-class DDI entry that idles the whole adapter ("the graphics hardware is
|
||||
//! idle") without changing anything Win32-visible.
|
||||
//!
|
||||
//! Every operation prints `epoch_ms op target duration_ms result` so stalls in a concurrent
|
||||
//! stream's host.log correlate line-for-line. The per-op duration is itself measurement: it is
|
||||
//! the I2C/modeset service time the GPU driver spent, per disturbance.
|
||||
//!
|
||||
//! Usage: `display-disturb ddc [--interval-ms 2000] [--caps] [--vcp 0x10]`
|
||||
//! `display-disturb modeset [--interval-ms 2000]`
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn main() {
|
||||
eprintln!("display-disturb is Windows-only (it exercises the WDDM display stack).");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn main() {
|
||||
win::main()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod win {
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use windows::{
|
||||
core::{BOOL, PCWSTR},
|
||||
Win32::{
|
||||
Devices::Display::{
|
||||
CapabilitiesRequestAndCapabilitiesReply, DestroyPhysicalMonitor,
|
||||
GetCapabilitiesStringLength, GetNumberOfPhysicalMonitorsFromHMONITOR,
|
||||
GetPhysicalMonitorsFromHMONITOR, GetVCPFeatureAndVCPFeatureReply, SetDisplayConfig,
|
||||
PHYSICAL_MONITOR, SDC_APPLY, SDC_TOPOLOGY_EXTEND,
|
||||
},
|
||||
Foundation::{HANDLE, LPARAM, RECT},
|
||||
Graphics::Gdi::{
|
||||
ChangeDisplaySettingsExW, EnumDisplayDevicesW, EnumDisplayMonitors,
|
||||
EnumDisplaySettingsW, GetMonitorInfoW, CDS_RESET, DEVMODEW, DISPLAY_DEVICEW,
|
||||
DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICE_MIRRORING_DRIVER,
|
||||
DISP_CHANGE_SUCCESSFUL, ENUM_CURRENT_SETTINGS, HDC, HMONITOR, MONITORINFOEXW,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fn epoch_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// One timed op: print the correlation line the bench greps for.
|
||||
fn report(op: &str, target: &str, took: Duration, ok: bool) {
|
||||
println!(
|
||||
"{} {op} {target} took_ms={} ok={ok}",
|
||||
epoch_ms(),
|
||||
took.as_millis()
|
||||
);
|
||||
}
|
||||
|
||||
struct Args {
|
||||
mode: String,
|
||||
interval: Duration,
|
||||
caps: bool,
|
||||
vcp: u8,
|
||||
}
|
||||
|
||||
fn parse_args() -> Args {
|
||||
let argv: Vec<String> = std::env::args().collect();
|
||||
let mode = argv.get(1).cloned().unwrap_or_default();
|
||||
if !matches!(mode.as_str(), "ddc" | "modeset" | "extend") {
|
||||
eprintln!(
|
||||
"usage: display-disturb <ddc|modeset|extend> [--interval-ms N] [--caps] [--vcp 0xNN]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let mut a = Args {
|
||||
mode,
|
||||
interval: Duration::from_millis(2000),
|
||||
caps: false,
|
||||
vcp: 0x10, // brightness — universally implemented, read-only harmless
|
||||
};
|
||||
let mut i = 2;
|
||||
while i < argv.len() {
|
||||
match argv[i].as_str() {
|
||||
"--interval-ms" => {
|
||||
i += 1;
|
||||
a.interval = Duration::from_millis(
|
||||
argv.get(i).and_then(|s| s.parse().ok()).unwrap_or(2000),
|
||||
);
|
||||
}
|
||||
"--caps" => a.caps = true,
|
||||
"--vcp" => {
|
||||
i += 1;
|
||||
let s = argv.get(i).map(String::as_str).unwrap_or("0x10");
|
||||
a.vcp = u8::from_str_radix(s.trim_start_matches("0x"), 16).unwrap_or(0x10);
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown arg: {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
pub fn main() {
|
||||
let args = parse_args();
|
||||
eprintln!(
|
||||
"display-disturb: mode={} interval={}ms (Ctrl-C to stop) — correlate `took_ms` lines \
|
||||
against the host log's stall reports",
|
||||
args.mode,
|
||||
args.interval.as_millis()
|
||||
);
|
||||
match args.mode.as_str() {
|
||||
"ddc" => ddc_loop(&args),
|
||||
"extend" => extend_once(),
|
||||
_ => modeset_loop(&args),
|
||||
}
|
||||
}
|
||||
|
||||
/// One `SetDisplayConfig(SDC_TOPOLOGY_EXTEND)` poke — re-activates every attachable display
|
||||
/// from the CCD database, i.e. exactly the "a non-managed display re-activated after the
|
||||
/// verified isolate" event the exclusive-topology watchdog exists to evict. Fired ONCE (not a
|
||||
/// loop): the point is to trigger one reassert round and observe the recovery — one
|
||||
/// `reassert-recover` trace, ONE ring recreate, stream alive.
|
||||
fn extend_once() -> ! {
|
||||
let t = Instant::now();
|
||||
// SAFETY: the no-buffers topology form of SetDisplayConfig; flags request the stored
|
||||
// EXTEND topology be applied — a pure CCD database operation with no pointers involved.
|
||||
let rc = unsafe { SetDisplayConfig(None, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY) };
|
||||
report("topology-extend", "all", t.elapsed(), rc == 0);
|
||||
std::process::exit(if rc == 0 { 0 } else { 1 });
|
||||
}
|
||||
|
||||
// ---- Class 2: the DDC hammer ----
|
||||
|
||||
/// Collect the desktop's HMONITORs (the DDC handles hang off them), labeled by GDI device
|
||||
/// name (`\\.\DISPLAYn`) so a virtual display and a panel are tellable apart in the output.
|
||||
fn monitors() -> Vec<(HMONITOR, String)> {
|
||||
unsafe extern "system" fn cb(mon: HMONITOR, _dc: HDC, _rc: *mut RECT, out: LPARAM) -> BOOL {
|
||||
let mut info = MONITORINFOEXW::default();
|
||||
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
|
||||
// SAFETY: `mon` is the live enumeration handle; `info.cbSize` is stamped for the EX
|
||||
// variant so the device-name field is filled.
|
||||
let name = if unsafe { GetMonitorInfoW(mon, &mut info.monitorInfo) }.as_bool() {
|
||||
let d = info.szDevice;
|
||||
String::from_utf16_lossy(&d[..d.iter().position(|&c| c == 0).unwrap_or(d.len())])
|
||||
} else {
|
||||
"?".into()
|
||||
};
|
||||
// SAFETY: `out.0` is the `&mut Vec<(HMONITOR, String)>` this enumeration call passed
|
||||
// in below, alive for the whole synchronous enumeration.
|
||||
unsafe { &mut *(out.0 as *mut Vec<(HMONITOR, String)>) }.push((mon, name));
|
||||
BOOL(1)
|
||||
}
|
||||
let mut v: Vec<(HMONITOR, String)> = Vec::new();
|
||||
// SAFETY: null dc/clip = enumerate all display monitors; `cb` only touches the Vec whose
|
||||
// address rides in LPARAM for the duration of this synchronous call.
|
||||
let _ =
|
||||
unsafe { EnumDisplayMonitors(None, None, Some(cb), LPARAM(&mut v as *mut _ as isize)) };
|
||||
v
|
||||
}
|
||||
|
||||
/// The physical (DDC-capable) monitors behind one HMONITOR, with their descriptions. The
|
||||
/// handle-ACQUISITION timing is itself reported (`ddc-open`): it is the poller's first contact
|
||||
/// with every monitor — including a virtual one that then yields no handles — and on a
|
||||
/// streaming host in exclusive topology the virtual monitor is the only one there is, so the
|
||||
/// cost of this failing path is exactly what the DDC-fail-fast driver work changes.
|
||||
fn physical_monitors(mon: HMONITOR, label: &str) -> Vec<(HANDLE, String)> {
|
||||
let t = Instant::now();
|
||||
let mut count = 0u32;
|
||||
// SAFETY: valid HMONITOR from enumeration; `count` is a valid out-param.
|
||||
if unsafe { GetNumberOfPhysicalMonitorsFromHMONITOR(mon, &mut count) }.is_err()
|
||||
|| count == 0
|
||||
{
|
||||
report("ddc-open", label, t.elapsed(), false);
|
||||
return Vec::new();
|
||||
}
|
||||
let mut phys = vec![PHYSICAL_MONITOR::default(); count as usize];
|
||||
// SAFETY: `phys` holds exactly `count` entries as the API requires.
|
||||
let got = unsafe { GetPhysicalMonitorsFromHMONITOR(mon, &mut phys) }.is_ok();
|
||||
report("ddc-open", label, t.elapsed(), got);
|
||||
if !got {
|
||||
return Vec::new();
|
||||
}
|
||||
phys.iter()
|
||||
.map(|p| {
|
||||
// Copy the field out: PHYSICAL_MONITOR is packed(1), so referencing the array
|
||||
// in place would be an unaligned reference (E0793).
|
||||
let name = p.szPhysicalMonitorDescription;
|
||||
let desc = String::from_utf16_lossy(
|
||||
&name[..name.iter().position(|&c| c == 0).unwrap_or(0)],
|
||||
);
|
||||
(p.hPhysicalMonitor, desc.trim().to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ddc_loop(args: &Args) -> ! {
|
||||
let mut warned_none = false;
|
||||
loop {
|
||||
let mut any = false;
|
||||
for (mon, dev) in monitors() {
|
||||
for (h, desc) in physical_monitors(mon, &dev) {
|
||||
any = true;
|
||||
let label = if desc.is_empty() {
|
||||
"monitor".into()
|
||||
} else {
|
||||
desc.clone()
|
||||
};
|
||||
if args.caps {
|
||||
// The heavy transaction: length + full capabilities string (the
|
||||
// PowerDisplay-discovery load, ~100 ms-1 s of serialized I2C).
|
||||
let t = Instant::now();
|
||||
let mut len = 0u32;
|
||||
// SAFETY: `h` is a live physical-monitor handle; `len` a valid out-param.
|
||||
let mut ok =
|
||||
unsafe { GetCapabilitiesStringLength(h, &mut len) } == 1 && len > 0;
|
||||
if ok {
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
// SAFETY: `buf` is exactly the reported capabilities length.
|
||||
ok = unsafe { CapabilitiesRequestAndCapabilitiesReply(h, &mut buf) }
|
||||
== 1;
|
||||
}
|
||||
report("ddc-caps", &label, t.elapsed(), ok);
|
||||
} else {
|
||||
let t = Instant::now();
|
||||
let (mut cur, mut max) = (0u32, 0u32);
|
||||
// SAFETY: `h` is a live physical-monitor handle; out-params are valid; a
|
||||
// read of a standard VCP code mutates nothing monitor-side.
|
||||
let ok = unsafe {
|
||||
GetVCPFeatureAndVCPFeatureReply(
|
||||
h,
|
||||
args.vcp,
|
||||
None,
|
||||
&mut cur,
|
||||
Some(&mut max),
|
||||
)
|
||||
} == 1;
|
||||
report("ddc-vcp", &label, t.elapsed(), ok);
|
||||
}
|
||||
// SAFETY: closing the handle GetPhysicalMonitorsFromHMONITOR returned.
|
||||
let _ = unsafe { DestroyPhysicalMonitor(h) };
|
||||
}
|
||||
}
|
||||
if !any && !warned_none {
|
||||
warned_none = true;
|
||||
eprintln!(
|
||||
"no DDC-capable (physical) monitor yielded handles — continuing anyway: the \
|
||||
ddc-open timing against handle-less monitors (virtual displays included) IS \
|
||||
the measurement on a streaming host"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(args.interval);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Class 1: the no-op modeset tick ----
|
||||
|
||||
/// The first attached, non-mirroring display device (device name, e.g. `\\.\DISPLAY1`).
|
||||
fn first_active_display() -> Option<[u16; 32]> {
|
||||
for i in 0u32.. {
|
||||
let mut dd = DISPLAY_DEVICEW {
|
||||
cb: std::mem::size_of::<DISPLAY_DEVICEW>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: null device name = enumerate adapters by index; `dd.cb` is stamped.
|
||||
if unsafe { !EnumDisplayDevicesW(PCWSTR::null(), i, &mut dd, 0).as_bool() } {
|
||||
return None;
|
||||
}
|
||||
if (dd.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP).0 != 0
|
||||
&& (dd.StateFlags & DISPLAY_DEVICE_MIRRORING_DRIVER).0 == 0
|
||||
{
|
||||
return Some(dd.DeviceName);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn modeset_loop(args: &Args) -> ! {
|
||||
let Some(name) = first_active_display() else {
|
||||
eprintln!("no active display device found");
|
||||
std::process::exit(1);
|
||||
};
|
||||
let label = String::from_utf16_lossy(
|
||||
&name[..name.iter().position(|&c| c == 0).unwrap_or(name.len())],
|
||||
);
|
||||
loop {
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: std::mem::size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `name` is the NUL-terminated device string from enumeration; `dm.dmSize`
|
||||
// is stamped; ENUM_CURRENT_SETTINGS fills the live mode.
|
||||
let got = unsafe {
|
||||
EnumDisplaySettingsW(PCWSTR(name.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm)
|
||||
}
|
||||
.as_bool();
|
||||
if !got {
|
||||
eprintln!("EnumDisplaySettingsW failed for {label}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let t = Instant::now();
|
||||
// SAFETY: re-applying the CURRENT mode with CDS_RESET — the same-mode re-commit that
|
||||
// forces the modeset path without changing anything user-visible. Null hwnd + null
|
||||
// lparam per the API contract for this flag combination.
|
||||
let rc = unsafe {
|
||||
ChangeDisplaySettingsExW(PCWSTR(name.as_ptr()), Some(&dm), None, CDS_RESET, None)
|
||||
};
|
||||
report(
|
||||
"modeset-reset",
|
||||
&label,
|
||||
t.elapsed(),
|
||||
rc == DISP_CHANGE_SUCCESSFUL,
|
||||
);
|
||||
std::thread::sleep(args.interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-9
@@ -45,6 +45,7 @@
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "^5.0.8",
|
||||
"dompurify": "^3.4.12",
|
||||
"fast-uri": "^3.1.4",
|
||||
"immutable": "^4.3.9",
|
||||
@@ -1116,7 +1117,7 @@
|
||||
|
||||
"body-scroll-lock": ["body-scroll-lock@4.0.0-beta.0", "", {}, "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
@@ -2544,8 +2545,6 @@
|
||||
|
||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
|
||||
|
||||
"rollup-plugin-visualizer/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"sass/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -2556,14 +2555,8 @@
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
|
||||
|
||||
"archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"sass/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -187,6 +187,20 @@
|
||||
"library_field_logo": "Logo-Bild-URL",
|
||||
"library_field_command": "Startbefehl",
|
||||
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
|
||||
"library_field_platform": "Plattform",
|
||||
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Beschreibung",
|
||||
"library_field_developer": "Entwickler",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Erscheinungsjahr",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Kommagetrennt, z. B. RPG, Plattformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Kommagetrennte Labels zum Organisieren, z. B. Koop, Kinder.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "z. B. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Spieler",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Speichern",
|
||||
"library_create": "Hinzufügen",
|
||||
"library_cancel": "Abbrechen",
|
||||
@@ -399,5 +413,6 @@
|
||||
"session_game_grace": "Zeitfenster für die Rückkehr",
|
||||
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
|
||||
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
|
||||
"session_game_inert": "Dieser Host kann keine Spiele starten – hier bewirken diese Einstellungen nichts"
|
||||
"session_game_inert": "Dieser Host kann keine Spiele starten – hier bewirken diese Einstellungen nichts",
|
||||
"session_game_nested_note": "In einer gamescope-Spielsitzung läuft das Spiel *innerhalb* der gestreamten Anzeige und lebt daher genau so lange wie diese – das entscheidet „Offen halten“ oben, nicht diese Einstellung. Ein bewusstes Stoppen reißt die Anzeige sofort ab und nimmt das Spiel mit, egal was du hier wählst."
|
||||
}
|
||||
|
||||
+16
-1
@@ -187,6 +187,20 @@
|
||||
"library_field_logo": "Logo art URL",
|
||||
"library_field_command": "Launch command",
|
||||
"library_field_command_help": "Optional. The command the host runs to launch this title.",
|
||||
"library_field_platform": "Platform",
|
||||
"library_field_platform_help": "The system this title runs on, e.g. PS2, Xbox 360, SNES, PC.",
|
||||
"library_field_description": "Description",
|
||||
"library_field_developer": "Developer",
|
||||
"library_field_publisher": "Publisher",
|
||||
"library_field_release_year": "Release year",
|
||||
"library_field_genres": "Genres",
|
||||
"library_field_genres_help": "Comma-separated, e.g. RPG, Platformer.",
|
||||
"library_field_tags": "Tags",
|
||||
"library_field_tags_help": "Comma-separated labels for organizing, e.g. co-op, kids.",
|
||||
"library_field_region": "Region",
|
||||
"library_field_region_help": "e.g. NTSC-U, PAL, NTSC-J.",
|
||||
"library_field_players": "Players",
|
||||
"library_details_legend": "Details (optional)",
|
||||
"library_save": "Save",
|
||||
"library_create": "Add",
|
||||
"library_cancel": "Cancel",
|
||||
@@ -399,5 +413,6 @@
|
||||
"session_game_grace": "Reconnect window",
|
||||
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
|
||||
"session_game_saved": "Session and game settings saved",
|
||||
"session_game_inert": "This host has no way to launch games, so these settings do nothing here"
|
||||
"session_game_inert": "This host has no way to launch games, so these settings do nothing here",
|
||||
"session_game_nested_note": "On a gamescope game session the game runs *inside* the streamed display, so it lives exactly as long as that display does — which is what Keep alive above decides, not this setting. A deliberate Stop tears that display down at once and takes the game with it, whichever option you pick here."
|
||||
}
|
||||
|
||||
+2
-1
@@ -65,6 +65,7 @@
|
||||
"immutable": "^4.3.9",
|
||||
"undici": "^7.28.0",
|
||||
"postcss": "^8.5.10",
|
||||
"js-yaml": "^4.3.0"
|
||||
"js-yaml": "^4.3.0",
|
||||
"brace-expansion": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +56,20 @@ const GameRow: FC<{
|
||||
const waiting = game.state === "grace";
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-16 w-11 shrink-0 overflow-hidden rounded bg-muted">
|
||||
{art && (
|
||||
{/* Fixed slot so rows line up whether or not a title has a cover. Plenty won't: an
|
||||
operator-typed command has no catalog entry behind it, a custom entry may carry no
|
||||
art, and nothing does until `/library` has loaded — an empty box reads as broken, so
|
||||
the placeholder says "game" instead of nothing. */}
|
||||
<div className="flex h-16 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
|
||||
{art ? (
|
||||
<img
|
||||
src={art}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Gamepad2 className="size-5 text-muted-foreground/60" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -116,6 +116,14 @@ export const SessionGameCard: FC = () => {
|
||||
{m.session_game_always_warning()}
|
||||
</p>
|
||||
)}
|
||||
{/* Shown for every option, including "leave it running": on a nested
|
||||
gamescope launch the game IS inside the streamed display, so the
|
||||
display's own keep-alive outranks anything chosen here — verified
|
||||
on glass (.41), where a deliberate stop ended the game under
|
||||
`keep`. Worded so a non-gamescope host reads it and moves on. */}
|
||||
<p className="max-w-prose text-xs text-muted-foreground">
|
||||
{m.session_game_nested_note()}
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
{(server.game_on_session_end ?? "keep") === "always" && (
|
||||
|
||||
@@ -65,13 +65,20 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
{game.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2">
|
||||
<div className="absolute left-2 top-2 flex flex-wrap gap-1">
|
||||
<Badge
|
||||
variant={isCustom ? "secondary" : "outline"}
|
||||
className="bg-background/80 backdrop-blur"
|
||||
>
|
||||
{storeLabel(game.store)}
|
||||
</Badge>
|
||||
{/* Platform badge — "PC" is implied by every installed store, so only
|
||||
non-PC platforms (the emulation case) earn a second badge. */}
|
||||
{game.platform && game.platform.toUpperCase() !== "PC" && (
|
||||
<Badge variant="outline" className="bg-background/80 backdrop-blur">
|
||||
{game.platform}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isCustom && (
|
||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
@@ -102,6 +109,11 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
title={game.title}
|
||||
>
|
||||
{game.title}
|
||||
{game.release_year != null && (
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{game.release_year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,17 @@ interface FormState {
|
||||
header: string;
|
||||
logo: string;
|
||||
command: string;
|
||||
// Details — the flattened GameMeta fields; numbers and lists are kept as the raw
|
||||
// text the user typed and only parsed on submit.
|
||||
platform: string;
|
||||
description: string;
|
||||
developer: string;
|
||||
publisher: string;
|
||||
releaseYear: string;
|
||||
genres: string;
|
||||
tags: string;
|
||||
region: string;
|
||||
players: string;
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
@@ -31,6 +42,15 @@ const emptyForm: FormState = {
|
||||
header: "",
|
||||
logo: "",
|
||||
command: "",
|
||||
platform: "",
|
||||
description: "",
|
||||
developer: "",
|
||||
publisher: "",
|
||||
releaseYear: "",
|
||||
genres: "",
|
||||
tags: "",
|
||||
region: "",
|
||||
players: "",
|
||||
};
|
||||
|
||||
function formFrom(entry: GameEntry): FormState {
|
||||
@@ -41,17 +61,38 @@ function formFrom(entry: GameEntry): FormState {
|
||||
header: entry.art.header ?? "",
|
||||
logo: entry.art.logo ?? "",
|
||||
command: entry.launch?.kind === "command" ? entry.launch.value : "",
|
||||
platform: entry.platform ?? "",
|
||||
description: entry.description ?? "",
|
||||
developer: entry.developer ?? "",
|
||||
publisher: entry.publisher ?? "",
|
||||
releaseYear: entry.release_year?.toString() ?? "",
|
||||
genres: entry.genres?.join(", ") ?? "",
|
||||
tags: entry.tags?.join(", ") ?? "",
|
||||
region: entry.region ?? "",
|
||||
players: entry.players?.toString() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Map the form to the API body — only attach `launch` when a command was given. `update_custom`
|
||||
* REPLACES the whole `art`, so every field the form knows must round-trip (else editing a game with
|
||||
* a `logo` would silently drop it). */
|
||||
* REPLACES the whole entry (art AND the metadata fields), so every field the form knows must
|
||||
* round-trip (else editing a game with a `logo` or a `platform` would silently drop it). */
|
||||
function toInput(f: FormState): CustomInput {
|
||||
const trim = (s: string) => {
|
||||
const t = s.trim();
|
||||
return t ? t : undefined;
|
||||
};
|
||||
// "RPG, Platformer" → ["RPG", "Platformer"]; empty input → omitted entirely.
|
||||
const list = (s: string) => {
|
||||
const items = s
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
return items.length ? items : undefined;
|
||||
};
|
||||
const int = (s: string) => {
|
||||
const n = Number.parseInt(s.trim(), 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
const command = f.command.trim();
|
||||
return {
|
||||
title: f.title.trim(),
|
||||
@@ -62,6 +103,15 @@ function toInput(f: FormState): CustomInput {
|
||||
logo: trim(f.logo),
|
||||
},
|
||||
launch: command ? { kind: "command", value: command } : null,
|
||||
platform: trim(f.platform),
|
||||
description: trim(f.description),
|
||||
developer: trim(f.developer),
|
||||
publisher: trim(f.publisher),
|
||||
release_year: int(f.releaseYear),
|
||||
genres: list(f.genres),
|
||||
tags: list(f.tags),
|
||||
region: trim(f.region),
|
||||
players: int(f.players),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,6 +151,32 @@ export const GameFormSection: FC<{
|
||||
);
|
||||
};
|
||||
|
||||
/** One labeled text input bound to a FormState key — the form is a stack of these. */
|
||||
const Field: FC<{
|
||||
id: keyof FormState;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
help?: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
}> = ({ id, label, value, onChange, help, type, required }) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`lib-${id}`}>{label}</Label>
|
||||
<Input
|
||||
id={`lib-${id}`}
|
||||
type={type}
|
||||
inputMode={
|
||||
type === "url" ? "url" : type === "number" ? "numeric" : undefined
|
||||
}
|
||||
required={required}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The add/edit form card. Owns only its own field state (re-seeded per mount — the
|
||||
* parent keys it by target); reports a ready-to-send `CustomInput` on submit.
|
||||
@@ -113,6 +189,8 @@ export const GameForm: FC<{
|
||||
isSaving: boolean;
|
||||
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
const set = (key: keyof FormState) => (value: string) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -138,74 +216,121 @@ export const GameForm: FC<{
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
|
||||
<Input
|
||||
id="lib-title"
|
||||
required
|
||||
value={form.title}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, title: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label>
|
||||
<Input
|
||||
id="lib-portrait"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={form.portrait}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, portrait: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
|
||||
<Input
|
||||
id="lib-hero"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={form.hero}
|
||||
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
|
||||
<Input
|
||||
id="lib-header"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={form.header}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, header: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lib-logo">{m.library_field_logo()}</Label>
|
||||
<Input
|
||||
id="lib-logo"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={form.logo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, logo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
|
||||
<Input
|
||||
id="lib-command"
|
||||
value={form.command}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, command: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.library_field_command_help()}
|
||||
<Field
|
||||
id="title"
|
||||
label={m.library_field_title()}
|
||||
value={form.title}
|
||||
onChange={set("title")}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
id="portrait"
|
||||
label={m.library_field_portrait()}
|
||||
value={form.portrait}
|
||||
onChange={set("portrait")}
|
||||
type="url"
|
||||
/>
|
||||
<Field
|
||||
id="hero"
|
||||
label={m.library_field_hero()}
|
||||
value={form.hero}
|
||||
onChange={set("hero")}
|
||||
type="url"
|
||||
/>
|
||||
<Field
|
||||
id="header"
|
||||
label={m.library_field_header()}
|
||||
value={form.header}
|
||||
onChange={set("header")}
|
||||
type="url"
|
||||
/>
|
||||
<Field
|
||||
id="logo"
|
||||
label={m.library_field_logo()}
|
||||
value={form.logo}
|
||||
onChange={set("logo")}
|
||||
type="url"
|
||||
/>
|
||||
<Field
|
||||
id="command"
|
||||
label={m.library_field_command()}
|
||||
value={form.command}
|
||||
onChange={set("command")}
|
||||
help={m.library_field_command_help()}
|
||||
/>
|
||||
<fieldset className="space-y-4 border-t pt-2">
|
||||
<legend className="sr-only">{m.library_details_legend()}</legend>
|
||||
<p
|
||||
aria-hidden
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{m.library_details_legend()}
|
||||
</p>
|
||||
</div>
|
||||
<Field
|
||||
id="platform"
|
||||
label={m.library_field_platform()}
|
||||
value={form.platform}
|
||||
onChange={set("platform")}
|
||||
help={m.library_field_platform_help()}
|
||||
/>
|
||||
<Field
|
||||
id="description"
|
||||
label={m.library_field_description()}
|
||||
value={form.description}
|
||||
onChange={set("description")}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
id="developer"
|
||||
label={m.library_field_developer()}
|
||||
value={form.developer}
|
||||
onChange={set("developer")}
|
||||
/>
|
||||
<Field
|
||||
id="publisher"
|
||||
label={m.library_field_publisher()}
|
||||
value={form.publisher}
|
||||
onChange={set("publisher")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
id="releaseYear"
|
||||
label={m.library_field_release_year()}
|
||||
value={form.releaseYear}
|
||||
onChange={set("releaseYear")}
|
||||
type="number"
|
||||
/>
|
||||
<Field
|
||||
id="players"
|
||||
label={m.library_field_players()}
|
||||
value={form.players}
|
||||
onChange={set("players")}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
id="region"
|
||||
label={m.library_field_region()}
|
||||
value={form.region}
|
||||
onChange={set("region")}
|
||||
help={m.library_field_region_help()}
|
||||
/>
|
||||
<Field
|
||||
id="genres"
|
||||
label={m.library_field_genres()}
|
||||
value={form.genres}
|
||||
onChange={set("genres")}
|
||||
help={m.library_field_genres_help()}
|
||||
/>
|
||||
<Field
|
||||
id="tags"
|
||||
label={m.library_field_tags()}
|
||||
value={form.tags}
|
||||
onChange={set("tags")}
|
||||
help={m.library_field_tags_help()}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={isSaving || !form.title.trim()}>
|
||||
{mode === "edit" ? m.library_save() : m.library_create()}
|
||||
|
||||
@@ -13,6 +13,15 @@ const emptyForm = {
|
||||
header: "",
|
||||
logo: "",
|
||||
command: "",
|
||||
platform: "",
|
||||
description: "",
|
||||
developer: "",
|
||||
publisher: "",
|
||||
releaseYear: "",
|
||||
genres: "",
|
||||
tags: "",
|
||||
region: "",
|
||||
players: "",
|
||||
};
|
||||
|
||||
// The overview grid and the add/edit form are separate components now, so the stories
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user