Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,82 @@ 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 }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+261
-147
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:\…`).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,18 @@ 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).
|
||||
; 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)"
|
||||
; 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 +280,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 +302,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,6 +341,19 @@ Filename: "powershell.exe"; \
|
||||
#endif
|
||||
|
||||
[Code]
|
||||
{ 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 := ExpandConstant('{commonappdata}\punktfunk\host.env');
|
||||
end;
|
||||
|
||||
{ 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
|
||||
@@ -359,6 +372,8 @@ 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;
|
||||
@@ -366,7 +381,12 @@ begin
|
||||
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
|
||||
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
|
||||
if Found <> '' then
|
||||
Result := MsgBox(
|
||||
{ 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(
|
||||
'Another game-streaming host is already installed on this PC:' + #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 ' +
|
||||
@@ -374,15 +394,24 @@ begin
|
||||
'already in use" errors and capture glitches.' + #13#10#13#10 +
|
||||
'Stop and uninstall the other host before using Punktfunk.' + #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 +421,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 +480,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,122 @@
|
||||
// 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);
|
||||
}
|
||||
{
|
||||
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,53 @@
|
||||
# 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 opt out of individual tasks, callers use Inno's ! negation via --override, e.g.
|
||||
# winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream"
|
||||
# Task names: installdriver, installgamepad, installaudiocable, installhdrlayer,
|
||||
# gamestream, allowpublicfw, startservice, trayicon
|
||||
Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART /SP-
|
||||
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,72 @@
|
||||
# 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 enabled by default
|
||||
Agreement: >-
|
||||
The default install enables GameStream so stock Moonlight clients can connect. GameStream uses
|
||||
legacy plain-HTTP pairing and is intended for trusted LANs only. To install without it, use
|
||||
--override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream", or run
|
||||
--interactive and clear the GameStream checkbox. It can also be changed later via
|
||||
PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env.
|
||||
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"
|
||||
+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>>
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,6 +153,23 @@ export const library: GameEntry[] = [
|
||||
art: noArt,
|
||||
launch: null,
|
||||
},
|
||||
// An emulated title with the metadata fields filled — exercises the platform
|
||||
// badge (non-PC) and the year in the caption.
|
||||
{
|
||||
id: "custom:sotc",
|
||||
store: "custom",
|
||||
title: "Shadow of the Colossus",
|
||||
art: noArt,
|
||||
launch: null,
|
||||
platform: "PS2",
|
||||
developer: "Team Ico",
|
||||
publisher: "Sony Computer Entertainment",
|
||||
release_year: 2005,
|
||||
genres: ["Adventure"],
|
||||
tags: ["favorite"],
|
||||
region: "PAL",
|
||||
players: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// --- Performance (stats) page ------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user