Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f9a06d51f | |||
| 7f639f7cf5 | |||
| 2c0aee3979 | |||
| ec84b30eae | |||
| d579cd318e | |||
| bb755ef7d2 | |||
| 08a397cf08 | |||
| 11cca33300 | |||
| 4d2632a845 | |||
| 17a262aace | |||
| 205e5c5a59 | |||
| 3584b47fb8 | |||
| de651c0f1a | |||
| e7a8cf2bba | |||
| fb92dcd1c2 | |||
| 8e75f3d8bf | |||
| 5e1e64e50b | |||
| fbe1e62ef2 | |||
| df04c2c54f | |||
| 1bc156aab0 | |||
| 399ea9a0f1 | |||
| 6f409caac7 | |||
| 055b95a181 | |||
| f4b52d0bf5 |
@@ -1,4 +1,4 @@
|
|||||||
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
|
# Build the punktfunk-host / punktfunk-client / punktfunk-web / punktfunk-scripting pacman packages from
|
||||||
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
|
||||||
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
|
||||||
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
|
||||||
@@ -45,8 +45,9 @@ jobs:
|
|||||||
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
git nodejs rust clang cmake nasm pkgconf python vulkan-headers \
|
||||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||||
mesa libglvnd unzip libarchive
|
mesa libglvnd unzip libarchive
|
||||||
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
|
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||||
# it's AUR-only on Arch, so bootstrap the official binary.
|
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
|
||||||
|
# the official binary.
|
||||||
command -v bun >/dev/null || {
|
command -v bun >/dev/null || {
|
||||||
curl -fsSL https://bun.sh/install | bash
|
curl -fsSL https://bun.sh/install | bash
|
||||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||||
@@ -105,7 +106,7 @@ jobs:
|
|||||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||||
mkdir -p dist && chown builder: dist
|
mkdir -p dist && chown builder: dist
|
||||||
cd packaging/arch
|
cd packaging/arch
|
||||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
|
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
|
||||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||||
makepkg -f -d --holdver
|
makepkg -f -d --holdver
|
||||||
|
|||||||
+121
-18
@@ -1,8 +1,18 @@
|
|||||||
# Build the punktfunk-host and punktfunk-client .debs and publish them to Gitea's Debian
|
# Build the punktfunk .debs and publish them to Gitea's Debian package registry, so Ubuntu
|
||||||
# package registry, so Ubuntu boxes get new builds via `apt update && apt upgrade`. Runs
|
# boxes get new builds via `apt update && apt upgrade`. Two jobs, both publishing to the same
|
||||||
# inside the same Ubuntu 26.04 rust-ci builder image as ci.yml, so dpkg-shlibdeps pins the
|
# apt distribution/component:
|
||||||
# runtime lib package names (libavcodec62, libpipewire-0.3-0t64, …) to exactly what the
|
#
|
||||||
# target boxes run.
|
# build-publish — client + web + scripting, on the Ubuntu 26.04 rust-ci image (the client
|
||||||
|
# needs 24.04-absent libs: SDL3, GTK4 ≥ 4.20).
|
||||||
|
# build-publish-host — the HOST, on the Ubuntu 24.04 rust-ci-noble image with a from-source
|
||||||
|
# FFmpeg 8 BUNDLED into the .deb. This lowers the host's glibc floor to 2.39
|
||||||
|
# and removes the hard `Depends: libavcodec62`, so the ONE host .deb installs
|
||||||
|
# on Ubuntu 24.04 LTS through 26.04. (A 26.04-built host .deb is uninstallable
|
||||||
|
# on 24.04 — the reason this job exists; see packaging/debian/README.md.)
|
||||||
|
#
|
||||||
|
# Both compute VERSION identically (scripts/ci/pf-version.sh is deterministic per commit), so the
|
||||||
|
# host and client packages always share a version line. The release-attach helpers are race-safe
|
||||||
|
# (ensure_release create-or-fetch; upsert_asset only conflicts on same-name), so the jobs run parallel.
|
||||||
#
|
#
|
||||||
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
# Registry (public, unom org): https://git.unom.io/unom/-/packages
|
||||||
# Box setup (once): see packaging/debian/README.md
|
# Box setup (once): see packaging/debian/README.md
|
||||||
@@ -83,22 +93,15 @@ jobs:
|
|||||||
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||||
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
restore-keys: cargo-target-v3-${{ env.rustc }}-
|
||||||
|
|
||||||
- name: Build release host + client
|
- name: Build release clients
|
||||||
env:
|
env:
|
||||||
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
|
||||||
run: |
|
run: |
|
||||||
git config --global --add safe.directory "$PWD"
|
git config --global --add safe.directory "$PWD"
|
||||||
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
# punktfunk-client-session is the Vulkan/Skia streamer the shell execs for a connect —
|
||||||
# both client binaries must ship (build-client-deb.sh installs both).
|
# both client binaries must ship (build-client-deb.sh installs both). The HOST is built
|
||||||
# --features punktfunk-host/nvenc: the direct-SDK NVENC path (real RFI + recovery anchor on
|
# separately in the build-publish-host job (Ubuntu 24.04 image + bundled FFmpeg 8).
|
||||||
# Linux NVIDIA; design/linux-direct-nvenc.md). AMD/Intel-safe — NVENC/CUDA is dlopen'd at
|
cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
# runtime (no link-time dep; identical DT_NEEDED to a plain build), and the encoder is only
|
|
||||||
# constructed for a CUDA capture frame + PUNKTFUNK_NVENC_DIRECT, never on VAAPI hosts.
|
|
||||||
# --features punktfunk-host/vulkan-encode: the AMD/Intel twin — raw VK_KHR_video_encode_h265
|
|
||||||
# with real RFI (design/linux-vulkan-video-encode.md). Pure Rust ash (no new lib / link dep);
|
|
||||||
# default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 → libav VAAPI), failed open falls back to VAAPI.
|
|
||||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
|
||||||
|
|
||||||
- name: Build + smoke-boot web console (bun preset)
|
- name: Build + smoke-boot web console (bun preset)
|
||||||
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
|
||||||
@@ -128,10 +131,12 @@ jobs:
|
|||||||
- name: Build .debs
|
- name: Build .debs
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
VERSION="$VERSION" bash packaging/debian/build-deb.sh
|
# host .deb is built in build-publish-host (Ubuntu 24.04 image); this job ships the rest.
|
||||||
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
|
||||||
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
# Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
|
||||||
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
|
||||||
|
# The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics.
|
||||||
|
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh
|
||||||
|
|
||||||
- name: Publish to the Gitea apt registry
|
- name: Publish to the Gitea apt registry
|
||||||
env:
|
env:
|
||||||
@@ -166,3 +171,101 @@ jobs:
|
|||||||
for DEB in dist/*.deb; do
|
for DEB in dist/*.deb; do
|
||||||
upsert_asset "$RID" "$DEB"
|
upsert_asset "$RID" "$DEB"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# The HOST .deb — built on Ubuntu 24.04 (noble) with a from-source FFmpeg 8 BUNDLED, so it installs
|
||||||
|
# on Ubuntu 24.04 LTS through 26.04 (see the file header + packaging/debian/README.md). Runs in
|
||||||
|
# parallel with build-publish and publishes to the SAME distribution/component; the version step is
|
||||||
|
# byte-identical so host and clients share a version line.
|
||||||
|
build-publish-host:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container:
|
||||||
|
image: git.unom.io/unom/punktfunk-rust-ci-noble:latest
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Version + channel
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
|
||||||
|
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
|
||||||
|
*) V="${PF_BASE}~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
|
||||||
|
esac
|
||||||
|
echo "VERSION=$V" >> "$GITHUB_ENV"
|
||||||
|
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
|
||||||
|
echo "host package version $V -> apt distribution '$DIST'"
|
||||||
|
|
||||||
|
# dpkg-shlibdeps/dpkg-deb + patchelf are baked into rust-ci-noble; python3 is for the
|
||||||
|
# release-attach helper. Re-install defensively so the job stays green against the PREVIOUS
|
||||||
|
# image on the same push (docker.yml bootstrap lag) — a no-op once the image ships them.
|
||||||
|
- name: dpkg-dev + patchelf + python3
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends dpkg-dev patchelf python3
|
||||||
|
|
||||||
|
- name: Cache keys
|
||||||
|
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/cargo/registry
|
||||||
|
/usr/local/cargo/git
|
||||||
|
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-home-
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target
|
||||||
|
# Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share
|
||||||
|
# ci.yml's 26.04 target cache (mixing would poison both).
|
||||||
|
key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-target-noble-v1-${{ env.rustc }}-
|
||||||
|
|
||||||
|
- name: Build release host
|
||||||
|
env:
|
||||||
|
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binary (build.rs)
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$PWD"
|
||||||
|
# Same features the old combined build used: --nvenc (direct-SDK NVENC, real RFI on NVIDIA;
|
||||||
|
# NVENC/CUDA is dlopen'd — no link dep, so this image needs no libcuda stub) + --vulkan-encode
|
||||||
|
# (raw VK_KHR_video_encode_h265 on AMD/Intel, pure ash). punktfunk-tray also ships in the host
|
||||||
|
# .deb (build-deb.sh builds+installs it). ffmpeg-sys-next links the image's bundled FFmpeg 8
|
||||||
|
# via PKG_CONFIG_PATH (set in rust-ci-noble).
|
||||||
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
|
-p punktfunk-host -p punktfunk-tray
|
||||||
|
|
||||||
|
- name: Build host .deb (FFmpeg bundled)
|
||||||
|
# BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the
|
||||||
|
# binary's rpath, so there is no `Depends: libavcodec62` to block install on 24.04. FFMPEG_PREFIX
|
||||||
|
# defaults to /opt/ffmpeg (the image's ENV also sets it).
|
||||||
|
run: |
|
||||||
|
VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh
|
||||||
|
|
||||||
|
- name: Publish to the Gitea apt registry
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
for DEB in dist/*.deb; do
|
||||||
|
echo "uploading $DEB"
|
||||||
|
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||||
|
VER=$(dpkg-deb -f "$DEB" Version)
|
||||||
|
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||||
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||||
|
done
|
||||||
|
echo "published host to $OWNER/debian $DISTRIBUTION/$COMPONENT"
|
||||||
|
|
||||||
|
- name: Attach the host .deb to the Gitea release (stable tags only)
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
. scripts/ci/gitea-release.sh
|
||||||
|
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
|
||||||
|
for DEB in dist/*.deb; do
|
||||||
|
upsert_asset "$RID" "$DEB"
|
||||||
|
done
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ jobs:
|
|||||||
- image: punktfunk-rust-ci
|
- image: punktfunk-rust-ci
|
||||||
dockerfile: ci/rust-ci.Dockerfile
|
dockerfile: ci/rust-ci.Dockerfile
|
||||||
context: ci
|
context: ci
|
||||||
|
# Ubuntu 24.04 LTS host builder: same purpose as rust-ci but lowers the host .deb's glibc
|
||||||
|
# floor to 2.39 and bundles a from-source FFmpeg 8, so the package installs on 24.04 LTS
|
||||||
|
# (rust-ci's 26.04 build is uninstallable there). Consumed by deb.yml's build-publish-host job.
|
||||||
|
- image: punktfunk-rust-ci-noble
|
||||||
|
dockerfile: ci/rust-ci-noble.Dockerfile
|
||||||
|
context: ci
|
||||||
- image: punktfunk-fedora-rpm
|
- image: punktfunk-fedora-rpm
|
||||||
dockerfile: ci/fedora-rpm.Dockerfile
|
dockerfile: ci/fedora-rpm.Dockerfile
|
||||||
context: ci
|
context: ci
|
||||||
|
|||||||
@@ -258,9 +258,12 @@ jobs:
|
|||||||
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
# sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support
|
||||||
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
# provisioning profiles"). Automatic signing assigns a profile only to the app and leaves
|
||||||
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
# the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox
|
||||||
# entitlements in. No -allowProvisioningUpdates → it stays OFFLINE and never cloud-signs
|
# entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and
|
||||||
# (the App-Manager ASC key can't), so the runner must have a macOS *development* profile
|
# regenerate the managed *development* profile — needed because the App Groups capability
|
||||||
# for io.unom.punktfunk installed. DISTRIBUTION signing happens in the export step below
|
# (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached
|
||||||
|
# one. This is DEVELOPMENT signing against the Apple Development cert already in the
|
||||||
|
# keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export
|
||||||
|
# step below
|
||||||
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
# (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store
|
||||||
# distribution profile that export needs.
|
# distribution profile that export needs.
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
@@ -271,6 +274,10 @@ jobs:
|
|||||||
-destination 'generic/platform=macOS' \
|
-destination 'generic/platform=macOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
|
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||||
|
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Automatic \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
@@ -308,11 +315,16 @@ jobs:
|
|||||||
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
# license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER
|
||||||
# this step used to set matched it and failed the archive ("does not support provisioning
|
# this step used to set matched it and failed the archive ("does not support provisioning
|
||||||
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
# profiles"). Automatic signing profiles only the app and leaves the resource bundle (and
|
||||||
# the macOS-host macro plugins) alone. No -allowProvisioningUpdates → OFFLINE, never
|
# the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID
|
||||||
# cloud-signs (the App-Manager ASC key can't), so the runner needs an iOS *development*
|
# capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk
|
||||||
# profile for io.unom.punktfunk installed. DISTRIBUTION signing is the export step below
|
# AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups
|
||||||
# (manual, via the plist). A running Xcode.app prunes unrecognized profiles — quit it so the
|
# capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension)
|
||||||
# manually-installed App Store distribution profile survives for export.
|
# invalidated the cached managed dev profile, which had no widgets profile at all. This is
|
||||||
|
# DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert
|
||||||
|
# creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles).
|
||||||
|
# DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected.
|
||||||
|
# A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed
|
||||||
|
# App Store distribution profile survives for export.
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk iOS App Store Distribution"
|
PROFILE="Punktfunk iOS App Store Distribution"
|
||||||
@@ -325,6 +337,10 @@ jobs:
|
|||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
|
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||||
|
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Automatic \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
@@ -366,9 +382,11 @@ jobs:
|
|||||||
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
# resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the
|
||||||
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
# sdk-scoped profile this step used to set; Automatic signing profiles only the app and
|
||||||
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
# leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/
|
||||||
# AssociationMacro) alone. No -allowProvisioningUpdates → OFFLINE, never cloud-signs (the
|
# AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities
|
||||||
# App-Manager ASC key can't), so the runner needs a tvOS *development* profile for
|
# and regenerate the managed *development* profile — the tvOS app carries the App Groups key
|
||||||
# io.unom.punktfunk installed. DISTRIBUTION signing is the export step below (manual, plist).
|
# (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against
|
||||||
|
# the Apple Development cert already in the keychain, so the App-Manager ASC key suffices.
|
||||||
|
# DISTRIBUTION signing is the export step below (manual, plist).
|
||||||
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true
|
||||||
pkill -x Xcode 2>/dev/null || true
|
pkill -x Xcode 2>/dev/null || true
|
||||||
PROFILE="Punktfunk tvOS App Store Distribution"
|
PROFILE="Punktfunk tvOS App Store Distribution"
|
||||||
@@ -377,6 +395,10 @@ jobs:
|
|||||||
-destination 'generic/platform=tvOS' \
|
-destination 'generic/platform=tvOS' \
|
||||||
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
|
||||||
-skipMacroValidation -skipPackagePluginValidation \
|
-skipMacroValidation -skipPackagePluginValidation \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
|
||||||
|
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
|
||||||
|
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \
|
||||||
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \
|
||||||
CODE_SIGN_STYLE=Automatic \
|
CODE_SIGN_STYLE=Automatic \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||||
|
|||||||
@@ -92,9 +92,10 @@ jobs:
|
|||||||
echo "rpm $V-$R -> group '$GROUP'"
|
echo "rpm $V-$R -> group '$GROUP'"
|
||||||
|
|
||||||
- name: Build RPM
|
- name: Build RPM
|
||||||
# PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below
|
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
|
||||||
# globs it in; the host RPM Recommends it). Needs bun (ensured in Prep).
|
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
|
||||||
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh
|
# Recommends both). Both need bun (ensured in Prep).
|
||||||
|
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
|
||||||
|
|
||||||
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
- name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md)
|
||||||
env:
|
env:
|
||||||
@@ -131,7 +132,8 @@ jobs:
|
|||||||
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
|
||||||
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
|
||||||
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||||
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
|
||||||
|
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
|
||||||
|
|
||||||
- name: Publish the sysext feed
|
- name: Publish the sysext feed
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
|
||||||
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
|
||||||
# pf-vdisplay virtual-display driver + the web management console, run by a scheduled task on a bundled
|
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
|
||||||
# bun) from one signed setup.exe. Runs on a self-hosted windows-amd64 runner
|
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
|
||||||
|
# windows-amd64 runner
|
||||||
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
|
||||||
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
|
||||||
#
|
#
|
||||||
@@ -52,6 +53,7 @@ on:
|
|||||||
- 'packaging/windows/**'
|
- 'packaging/windows/**'
|
||||||
- 'scripts/windows/**'
|
- 'scripts/windows/**'
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
|
- 'sdk/**'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- '.gitea/workflows/windows-host.yml'
|
- '.gitea/workflows/windows-host.yml'
|
||||||
@@ -143,9 +145,18 @@ jobs:
|
|||||||
- name: Clippy (host + tray, Windows)
|
- name: Clippy (host + tray, Windows)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
|
||||||
|
# --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
|
||||||
|
# into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
|
||||||
|
# vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
|
||||||
|
# steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
|
||||||
|
# `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
|
||||||
|
# disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
|
||||||
|
# build minutes earlier). Linting in release reuses those native build-script artifacts (no
|
||||||
|
# openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
|
||||||
|
# pf-vkhdr-layer's clippy below runs --release.
|
||||||
run: |
|
run: |
|
||||||
cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
|
||||||
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
|
||||||
|
|
||||||
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -212,6 +223,24 @@ jobs:
|
|||||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
|
- name: Build plugin/script runner bundle (bun)
|
||||||
|
shell: pwsh
|
||||||
|
# `bun build --target=bun` bundles the SDK's runner CLI to ONE self-contained JS (effect + the
|
||||||
|
# SDK inlined; the dynamic plugin import stays a runtime import). pack-host-installer.ps1 ships
|
||||||
|
# it (+ the shared bun) and registers its scheduled task DISABLED (opt-in). The SDK's deps are
|
||||||
|
# public npm (effect), so no @unom token is needed here.
|
||||||
|
run: |
|
||||||
|
$bun = $env:BUN_EXE
|
||||||
|
Push-Location sdk
|
||||||
|
& $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "sdk bun install failed ($LASTEXITCODE)" }
|
||||||
|
New-Item -ItemType Directory -Force -Path C:\t\scripting | Out-Null
|
||||||
|
& $bun build src/runner-cli.ts --target=bun --outfile=C:\t\scripting\runner-cli.js; if ($LASTEXITCODE) { throw "runner bundle build failed ($LASTEXITCODE)" }
|
||||||
|
Pop-Location
|
||||||
|
if (-not (Select-String -Path C:\t\scripting\runner-cli.js -Pattern 'attempt=' -Quiet)) {
|
||||||
|
throw "runner bundle missing the dynamic plugin import - wrong build"
|
||||||
|
}
|
||||||
|
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Pack + sign installer
|
- name: Pack + sign installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ CLAUDE.md
|
|||||||
/result
|
/result
|
||||||
/result-*
|
/result-*
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|
||||||
|
# Gradle build output inside the vendored pyrowave Granite Android platform (regenerated, never ours to commit)
|
||||||
|
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/.gradle/
|
||||||
|
/crates/pyrowave-sys/vendor/pyrowave/Granite/application/platforms/android/**/build/
|
||||||
|
|||||||
Generated
+41
-37
@@ -358,28 +358,6 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aws-lc-rs"
|
|
||||||
version = "1.17.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
|
||||||
dependencies = [
|
|
||||||
"aws-lc-sys",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aws-lc-sys"
|
|
||||||
version = "0.41.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cmake",
|
|
||||||
"dunce",
|
|
||||||
"fs_extra",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -762,6 +740,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -1028,12 +1012,6 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dunce"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -1288,12 +1266,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fs_extra"
|
|
||||||
version = "1.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1510,6 +1482,16 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.22.6"
|
version = "0.22.6"
|
||||||
@@ -2011,9 +1993,13 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"gif",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
|
"zune-core",
|
||||||
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3650,7 +3636,6 @@ version = "0.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"pem",
|
"pem",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -3879,7 +3864,6 @@ version = "0.23.41"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -3944,7 +3928,6 @@ version = "0.103.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -5269,6 +5252,12 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wide"
|
name = "wide"
|
||||||
version = "0.7.33"
|
version = "0.7.33"
|
||||||
@@ -6081,6 +6070,21 @@ version = "1.0.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.12.0"
|
version = "5.12.0"
|
||||||
|
|||||||
@@ -1931,6 +1931,184 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/plugins": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugins"
|
||||||
|
],
|
||||||
|
"summary": "List registered plugins",
|
||||||
|
"description": "The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry\nreports its id, title, optional version, and — for plugins that serve one — a UI descriptor\n(loopback port + icon). The console renders these as nav entries and proxies to the port; it\nfetches the secret separately, server-side.",
|
||||||
|
"operationId": "listPlugins",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Live plugin registrations",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PluginSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/plugins/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"plugins"
|
||||||
|
],
|
||||||
|
"summary": "Register or renew a plugin",
|
||||||
|
"description": "Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs\nthis every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console\nwill proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed\n(first registration, restart, or re-scan) — a pure renewal is silent.",
|
||||||
|
"operationId": "registerPlugin",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PluginRegistration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Registered / renewed"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid id or registration",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"plugins"
|
||||||
|
],
|
||||||
|
"summary": "Deregister a plugin",
|
||||||
|
"description": "The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls\nthis from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was\nremoved. Idempotent — deleting an unknown/expired id is a no-op `204`.",
|
||||||
|
"operationId": "deregisterPlugin",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The plugin id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Deregistered (or already absent)"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/plugins/{id}/ui-credential": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugins"
|
||||||
|
],
|
||||||
|
"summary": "Fetch a plugin UI's proxy credential",
|
||||||
|
"description": "Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.\nBearer + loopback only (like every mutation), and additionally excluded from the console's browser\npassthrough: the secret never reaches a browser.",
|
||||||
|
"operationId": "getPluginUiCredential",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"description": "The plugin id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The proxy credential",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UiCredential"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No live plugin with that id, or it serves no UI",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/session": {
|
"/api/v1/session": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3294,6 +3472,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The plugin whose registration changed (registered, restarted, deregistered, or\nlease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"plugins.changed"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4099,6 +4296,116 @@
|
|||||||
"gamestream"
|
"gamestream"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"PluginRegistration": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Register/renew body for `PUT /plugins/{id}`.",
|
||||||
|
"required": [
|
||||||
|
"title"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable title for the console nav entry (1–64 chars; control chars stripped)."
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PluginUi",
|
||||||
|
"description": "Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book\nentry only (e.g. a future runner-management listing) and grows no nav entry."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Optional plugin version, purely informational (≤32 chars)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists\nand has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PluginUiPublic"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginUi": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request\nbody, never a response ([`PluginUiPublic`] is the secret-free view).",
|
||||||
|
"required": [
|
||||||
|
"port",
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`)."
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "The **loopback** port the plugin serves its UI on. The host and console only ever dial\n`127.0.0.1:<port>`; a registration can never carry a hostname.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every\nrequest to the plugin's UI server. Rotated whenever the plugin restarts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginUiPublic": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.",
|
||||||
|
"required": [
|
||||||
|
"port"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"icon": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PortMap": {
|
"PortMap": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
|
"description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).",
|
||||||
@@ -4710,6 +5017,24 @@
|
|||||||
"primary",
|
"primary",
|
||||||
"exclusive"
|
"exclusive"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"UiCredential": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "`GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).\nThis is the only endpoint that returns a secret; the console BFF denylists it from the browser.",
|
||||||
|
"required": [
|
||||||
|
"port",
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securitySchemes": {
|
"securitySchemes": {
|
||||||
@@ -4772,6 +5097,10 @@
|
|||||||
{
|
{
|
||||||
"name": "hooks",
|
"name": "hooks",
|
||||||
"description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"
|
"description": "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "plugins",
|
||||||
|
"description": "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# LTS builder for the punktfunk HOST .deb — Ubuntu 24.04 (noble), the current Ubuntu LTS.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS (see packaging/debian/README.md → "Ubuntu 24.04 LTS"):
|
||||||
|
# The default builder (ci/rust-ci.Dockerfile) is Ubuntu 26.04, so the host .deb it produces bakes
|
||||||
|
# in a glibc 2.41 floor and a hard `Depends: libavcodec62, …` (FFmpeg 8). Ubuntu 24.04 LTS ships
|
||||||
|
# glibc 2.39 and FFmpeg 6.1 (libavcodec60), so that .deb is uninstallable there — apt reports the
|
||||||
|
# deps as "too recent". Building the host on 24.04 instead lowers the glibc floor to 2.39 (the
|
||||||
|
# binary then runs on 24.04 → 26.04), and the ONE library 24.04 is too old for — FFmpeg — is built
|
||||||
|
# from source here and BUNDLED into the .deb (packaging/debian/build-deb.sh, BUNDLE_FFMPEG=1), so
|
||||||
|
# the package no longer depends on the distro's libav* at all. Everything else the host links
|
||||||
|
# (PipeWire, Wayland, xkbcommon, GL/EGL/GBM, Vulkan; opus is vendored via cmake) is soname-compatible
|
||||||
|
# on 24.04, so this ONE universal host .deb replaces the 26.04-built one for every Ubuntu user.
|
||||||
|
#
|
||||||
|
# libcuda is deliberately NOT provided: the host dlopen's libcuda.so.1 at runtime (pf-zerocopy /
|
||||||
|
# pf-encode) and never link-imports it, so — unlike the full-workspace rust-ci image, which builds
|
||||||
|
# tests that DO link a cuda stub — this host-only build needs no NVIDIA driver package. NVENC/EGL
|
||||||
|
# come from whatever driver the target runs, out of band.
|
||||||
|
#
|
||||||
|
# Rebuilt+pushed by .gitea/workflows/docker.yml (matrix: punktfunk-rust-ci-noble); consumed by the
|
||||||
|
# `build-publish-host` job in .gitea/workflows/deb.yml. Bootstrap: like rust-ci, the first deb.yml
|
||||||
|
# run after this image is added uses the image from a PRIOR docker.yml push — seed it once manually
|
||||||
|
# (docker build -f ci/rust-ci-noble.Dockerfile -t … ci && docker push) before the host job can run.
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps
|
||||||
|
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
|
||||||
|
# .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg
|
||||||
|
dpkg-dev patchelf \
|
||||||
|
# FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel
|
||||||
|
# encode backend the host auto-selects; zlib (libavformat). NVENC needs only headers (below), dlopen'd.
|
||||||
|
nasm libva-dev libdrm-dev zlib1g-dev \
|
||||||
|
# host link deps present on 24.04 with sonames compatible up to 26.04
|
||||||
|
libpipewire-0.3-dev libwayland-dev libxkbcommon-dev \
|
||||||
|
libgl-dev libegl-dev libgbm-dev libvulkan-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# --- FFmpeg 8 from source -> /opt/ffmpeg (shared libs + .pc files) ----------------------------------
|
||||||
|
# libavcodec.so.62, matching the 26.04 line's soname so the host behaves identically. This is an
|
||||||
|
# LGPL build (no --enable-gpl / --enable-nonfree) so bundling the .so's into an MIT/Apache .deb stays
|
||||||
|
# license-clean — LGPL's relink clause is satisfied by dynamic linking, and the only encoders the host
|
||||||
|
# calls (h264/hevc/av1 _nvenc + _vaapi, plus scale_vaapi/hwmap filters; software H.264 fallback is the
|
||||||
|
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
|
||||||
|
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
|
||||||
|
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
|
||||||
|
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
|
||||||
|
ARG FFMPEG_TAG=n8.0
|
||||||
|
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
|
||||||
|
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
|
||||||
|
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
|
||||||
|
ARG NVHDR_TAG=n12.2.72.0
|
||||||
|
RUN set -eux; \
|
||||||
|
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
|
||||||
|
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
|
||||||
|
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
|
||||||
|
make -C /tmp/nvhdr install PREFIX=/usr/local; \
|
||||||
|
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
|
||||||
|
cd /tmp/ffmpeg; \
|
||||||
|
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
|
||||||
|
--prefix=/opt/ffmpeg \
|
||||||
|
--enable-shared --disable-static \
|
||||||
|
--disable-doc --disable-programs --disable-debug \
|
||||||
|
--enable-nvenc --enable-vaapi \
|
||||||
|
--extra-cflags=-I/usr/local/include --extra-ldflags=-L/usr/local/lib; \
|
||||||
|
make -j"$(nproc)"; make install; \
|
||||||
|
cd /; rm -rf /tmp/ffmpeg /tmp/nvhdr; \
|
||||||
|
# sanity: the soname we expect to bundle (libavcodec.so.62 on FFmpeg 8)
|
||||||
|
test -e /opt/ffmpeg/lib/libavcodec.so.62
|
||||||
|
|
||||||
|
# ffmpeg-sys-next discovers FFmpeg via pkg-config; point it at the bundled build. PKG_CONFIG_PATH is
|
||||||
|
# PREPENDED to pkg-config's default dirs (not a replacement — that's PKG_CONFIG_LIBDIR), so PipeWire /
|
||||||
|
# Wayland / libva / … still resolve from the system. FFMPEG_PREFIX is read by build-deb.sh's bundler.
|
||||||
|
ENV PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig \
|
||||||
|
FFMPEG_PREFIX=/opt/ffmpeg
|
||||||
|
|
||||||
|
# Toolchain shared across CI users (jobs may run as different uids).
|
||||||
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo \
|
||||||
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --no-modify-path --profile minimal \
|
||||||
|
--component rustfmt,clippy \
|
||||||
|
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||||
|
&& rustc --version && cargo clippy --version && cargo fmt --version
|
||||||
@@ -712,7 +712,7 @@
|
|||||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -757,7 +757,7 @@
|
|||||||
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
INFOPLIST_FILE = PunktfunkWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
INFOPLIST_KEY_CFBundleDisplayName = PunktfunkWidgets;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ struct StreamCommands: Commands {
|
|||||||
}
|
}
|
||||||
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
|
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
|
||||||
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
|
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
|
||||||
|
// Toggle the window's fullscreen. ⌃⌘F is the macOS-standard fullscreen combo; here it's
|
||||||
|
// explicit so it's discoverable AND survives capture — while streaming the stream view
|
||||||
|
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
|
||||||
|
// notification the key window's FullscreenController observes.
|
||||||
|
Button("Toggle Fullscreen") {
|
||||||
|
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||||
|
}
|
||||||
|
.keyboardShortcut("f", modifiers: [.control, .command])
|
||||||
#endif
|
#endif
|
||||||
Divider()
|
Divider()
|
||||||
Button("Disconnect") { session?.disconnect() }
|
Button("Disconnect") { session?.disconnect() }
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
renderScaleRow
|
||||||
bitrateRows
|
bitrateRows
|
||||||
#endif
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
@@ -54,6 +55,50 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
|
||||||
|
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
|
||||||
|
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
|
||||||
|
@ViewBuilder var renderScaleRow: some View {
|
||||||
|
Picker("Render scale", selection: $renderScale) {
|
||||||
|
ForEach(RenderScale.presets, id: \.self) { scale in
|
||||||
|
Text(RenderScale.label(scale)).tag(scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
|
||||||
|
// (match-window derives the base from the live window, not these fields).
|
||||||
|
if renderScale != 1.0, !matchWindow {
|
||||||
|
let mode = RenderScale.apply(
|
||||||
|
baseWidth: width, baseHeight: height,
|
||||||
|
scale: renderScale,
|
||||||
|
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||||
|
Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard & mouse forwarding — applies wherever a hardware keyboard/mouse drives the stream
|
||||||
|
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
|
||||||
|
@ViewBuilder var inputSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Modifier keys", selection: $modifierLayout) {
|
||||||
|
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||||
|
Text(layout.label).tag(layout.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Invert scroll direction", isOn: $invertScroll)
|
||||||
|
} header: {
|
||||||
|
Text("Keyboard & mouse")
|
||||||
|
} footer: {
|
||||||
|
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
|
||||||
|
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
|
||||||
|
.font(.geist(12, relativeTo: .caption))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// MARK: - Stream mode (iOS wheel)
|
// MARK: - Stream mode (iOS wheel)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
|
||||||
// there is no scaling anywhere in the pipeline.
|
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
||||||
|
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
||||||
//
|
//
|
||||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||||
@@ -25,6 +26,10 @@ struct SettingsView: View {
|
|||||||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||||||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||||||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||||||
|
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
|
||||||
|
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
|
||||||
|
// link). 1.0 = Native (the prior behaviour).
|
||||||
|
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@@ -51,6 +56,12 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||||||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||||||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||||||
|
#if !os(tvOS)
|
||||||
|
// Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips
|
||||||
|
// both wheel axes; modifier-layout relocates the ⌥/⌘ → Alt/Super roles by physical position.
|
||||||
|
@AppStorage(DefaultsKey.invertScroll) var invertScroll = false
|
||||||
|
@AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue
|
||||||
|
#endif
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State var showControllerTest = false
|
@State var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
@@ -112,6 +123,7 @@ struct SettingsView: View {
|
|||||||
TabView {
|
TabView {
|
||||||
Form {
|
Form {
|
||||||
streamModeSection
|
streamModeSection
|
||||||
|
inputSection
|
||||||
compositorSection
|
compositorSection
|
||||||
wakeSection
|
wakeSection
|
||||||
}
|
}
|
||||||
@@ -242,6 +254,7 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
streamModeSection
|
streamModeSection
|
||||||
pointerSection
|
pointerSection
|
||||||
|
inputSection
|
||||||
compositorSection
|
compositorSection
|
||||||
wakeSection
|
wakeSection
|
||||||
keepAliveSection // iOS-only content; empty on tvOS
|
keepAliveSection // iOS-only content; empty on tvOS
|
||||||
@@ -334,6 +347,10 @@ struct SettingsView: View {
|
|||||||
return ScrollView {
|
return ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Render scale",
|
||||||
|
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||||||
|
selection: $renderScale)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Bitrate",
|
title: "Bitrate",
|
||||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ public final class ClipboardSync: NSObject {
|
|||||||
("text/rtf", .rtf),
|
("text/rtf", .rtf),
|
||||||
("text/html", .html),
|
("text/html", .html),
|
||||||
("image/png", .png),
|
("image/png", .png),
|
||||||
|
// Original image formats pass through VERBATIM beside the PNG floor — a copied JPEG
|
||||||
|
// never balloons into PNG, a GIF keeps its animation; the destination picks the richest
|
||||||
|
// kind it can place.
|
||||||
|
("image/jpeg", NSPasteboard.PasteboardType("public.jpeg")),
|
||||||
|
("image/gif", NSPasteboard.PasteboardType("com.compuserve.gif")),
|
||||||
]
|
]
|
||||||
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
|
/// Pasteboard marker types that must never cross the wire (password managers mark secrets
|
||||||
/// with these — see nspasteboard.org).
|
/// with these — see nspasteboard.org).
|
||||||
@@ -205,11 +210,14 @@ public final class ClipboardSync: NSObject {
|
|||||||
var kinds = Self.wireToPasteboard
|
var kinds = Self.wireToPasteboard
|
||||||
.filter { types.contains($0.type) }
|
.filter { types.contains($0.type) }
|
||||||
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
.map { PunktfunkConnection.ClipKind(mime: $0.wire) }
|
||||||
// Images: macOS image copies usually carry TIFF (browsers add WebP/AVIF/GIF, screenshots
|
// PNG floor: announce the portable `image/png` whenever ANY convertible image is present
|
||||||
// TIFF) and only sometimes PNG — announce the portable `image/png` whenever ANY
|
// — native PNG, TIFF/HEIC (screenshots, Preview), or a JPEG/GIF original already being
|
||||||
// convertible image type is present; `serveFetch` converts at fetch time (lazy, §3.5).
|
// offered verbatim above. `readWireData` converts at fetch time (lazy, §3.5), so the
|
||||||
|
// fallback costs nothing unless a peer actually pastes it.
|
||||||
if !kinds.contains(where: { $0.mime == "image/png" }),
|
if !kinds.contains(where: { $0.mime == "image/png" }),
|
||||||
types.contains(.tiff) || types.contains(NSPasteboard.PasteboardType("public.heic"))
|
types.contains(.tiff)
|
||||||
|
|| types.contains(NSPasteboard.PasteboardType("public.heic"))
|
||||||
|
|| kinds.contains(where: { $0.mime.hasPrefix("image/") })
|
||||||
{
|
{
|
||||||
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
|
kinds.append(PunktfunkConnection.ClipKind(mime: "image/png"))
|
||||||
}
|
}
|
||||||
@@ -385,6 +393,8 @@ private final class RemoteOfferProvider: NSObject, NSPasteboardItemDataProvider
|
|||||||
case .rtf: return "text/rtf"
|
case .rtf: return "text/rtf"
|
||||||
case .html: return "text/html"
|
case .html: return "text/html"
|
||||||
case .png: return "image/png"
|
case .png: return "image/png"
|
||||||
|
case NSPasteboard.PasteboardType("public.jpeg"): return "image/jpeg"
|
||||||
|
case NSPasteboard.PasteboardType("com.compuserve.gif"): return "image/gif"
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import UIKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import GameController
|
import GameController
|
||||||
import PunktfunkCore
|
import PunktfunkCore
|
||||||
|
import PunktfunkShared
|
||||||
import os
|
import os
|
||||||
|
|
||||||
/// Diagnostic logging for the input path. Off by default (input is high-rate); set
|
/// Diagnostic logging for the input path. Off by default (input is high-rate); set
|
||||||
@@ -125,6 +126,12 @@ public final class InputCapture {
|
|||||||
public var onDisconnect: (() -> Void)?
|
public var onDisconnect: (() -> Void)?
|
||||||
public var onCycleStats: (() -> Void)?
|
public var onCycleStats: (() -> Void)?
|
||||||
|
|
||||||
|
/// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the
|
||||||
|
/// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view
|
||||||
|
/// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the
|
||||||
|
/// menu handles it. Main queue.
|
||||||
|
public var onToggleFullscreen: (() -> Void)?
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||||
@@ -273,6 +280,14 @@ public final class InputCapture {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ⌃⌘F toggles the streaming window's fullscreen. Intercepted only while forwarding (the
|
||||||
|
// captured stream view swallows the menu's identical equivalent); the F is latched so its
|
||||||
|
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
|
||||||
|
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
|
||||||
|
self.suppressedVK = 0x46 // VK_F — the same physical F is en route via GC
|
||||||
|
self.onToggleFullscreen?()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -318,7 +333,7 @@ public final class InputCapture {
|
|||||||
chordModifiersDown.removeAll()
|
chordModifiersDown.removeAll()
|
||||||
suppressedVK = nil
|
suppressedVK = nil
|
||||||
for vk in pressedVKs {
|
for vk in pressedVKs {
|
||||||
connection.send(.key(vk, down: false))
|
emitKey(vk, down: false)
|
||||||
}
|
}
|
||||||
for button in pressedButtons {
|
for button in pressedButtons {
|
||||||
connection.send(.mouseButton(button, down: false))
|
connection.send(.mouseButton(button, down: false))
|
||||||
@@ -331,6 +346,15 @@ public final class InputCapture {
|
|||||||
residualScrollY = 0
|
residualScrollY = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
|
||||||
|
/// active location-based modifier layout is applied in exactly one place while all internal
|
||||||
|
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
|
||||||
|
/// the physical VK. Read live from the setting so a mid-session change (rare) takes on the next
|
||||||
|
/// key without re-arming capture. Non-modifier VKs pass through untouched.
|
||||||
|
private func emitKey(_ vk: UInt32, down: Bool) {
|
||||||
|
connection.send(.key(Self.applyModifierLayout(vk, ModifierLayout.current), down: down))
|
||||||
|
}
|
||||||
|
|
||||||
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
|
||||||
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
|
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
|
||||||
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
|
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
|
||||||
@@ -399,7 +423,7 @@ public final class InputCapture {
|
|||||||
inputLog.debug(
|
inputLog.debug(
|
||||||
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
|
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
|
||||||
}
|
}
|
||||||
connection.send(.key(vk, down: down))
|
emitKey(vk, down: down)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp — they arrive
|
||||||
@@ -566,8 +590,15 @@ public final class InputCapture {
|
|||||||
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery
|
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel — the only delivery
|
||||||
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
|
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
|
||||||
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
|
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
|
||||||
public func sendScroll(dx: Float, dy: Float) {
|
public func sendScroll(dx rawDx: Float, dy rawDy: Float) {
|
||||||
guard forwarding else { return }
|
guard forwarding else { return }
|
||||||
|
// Optionally invert both axes (read live). This is the ONE scroll sink for every platform —
|
||||||
|
// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here — so the toggle
|
||||||
|
// flips them consistently. Residuals are accumulated AFTER inversion so a direction change
|
||||||
|
// between events doesn't strand a fractional remainder of the old sign.
|
||||||
|
let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll)
|
||||||
|
let dx = invert ? -rawDx : rawDx
|
||||||
|
let dy = invert ? -rawDy : rawDy
|
||||||
let fy = dy + residualScrollY
|
let fy = dy + residualScrollY
|
||||||
let fx = dx + residualScrollX
|
let fx = dx + residualScrollX
|
||||||
let iy = fy.rounded(.towardZero)
|
let iy = fy.rounded(.towardZero)
|
||||||
@@ -643,7 +674,7 @@ public final class InputCapture {
|
|||||||
} else {
|
} else {
|
||||||
self.pressedVKs.remove(vk)
|
self.pressedVKs.remove(vk)
|
||||||
}
|
}
|
||||||
self.connection.send(.key(vk, down: pressed))
|
self.emitKey(vk, down: pressed)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
// InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all
|
// InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all
|
||||||
// platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path).
|
// platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path).
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
extension InputCapture {
|
extension InputCapture {
|
||||||
|
/// Remap one modifier VK for the active location-based [`ModifierLayout`] just before it goes on
|
||||||
|
/// the wire. `.mac` (and every non-modifier VK) is the identity. `.windows` swaps the Alt vs
|
||||||
|
/// Super/Windows ROLE between the Option and Command keys while KEEPING the side, so a Windows
|
||||||
|
/// user's `⌘ = Alt, ⌥ = Win` muscle memory lands correctly:
|
||||||
|
/// L Command 0x5B ↔ L Alt 0xA4 · R Command 0x5C ↔ R Alt 0xA5.
|
||||||
|
/// It's applied ONLY at the send boundary (`InputCapture.emitKey`) — all press/release
|
||||||
|
/// bookkeeping stays on the physical VK, so modifier direction tracking and the client-local
|
||||||
|
/// ⌘-based shortcuts are untouched. The swap is its own inverse: a key that went down remapped
|
||||||
|
/// goes up remapped, so the host never sees a stuck modifier.
|
||||||
|
static func applyModifierLayout(_ vk: UInt32, _ layout: ModifierLayout) -> UInt32 {
|
||||||
|
guard layout == .windows else { return vk }
|
||||||
|
switch vk {
|
||||||
|
case 0x5B: return 0xA4 // L Command → L Alt (VK_LWIN → VK_LMENU)
|
||||||
|
case 0x5C: return 0xA5 // R Command → R Alt (VK_RWIN → VK_RMENU)
|
||||||
|
case 0xA4: return 0x5B // L Option → L Win (VK_LMENU → VK_LWIN)
|
||||||
|
case 0xA5: return 0x5C // R Option → R Win (VK_RMENU → VK_RWIN)
|
||||||
|
default: return vk
|
||||||
|
}
|
||||||
|
}
|
||||||
/// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted
|
/// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted
|
||||||
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together).
|
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together).
|
||||||
static let hidToVK: [Int: UInt32] = {
|
static let hidToVK: [Int: UInt32] = {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
// from looping request → rollback → request.
|
// from looping request → rollback → request.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
/// The pure, side-effect-free core of the Match-window trigger — so the normalize/skip discipline
|
||||||
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
/// is unit-tested without a live connection or a UI (`MatchWindowTests`).
|
||||||
@@ -51,6 +52,11 @@ public final class MatchWindowFollower {
|
|||||||
private weak var connection: PunktfunkConnection?
|
private weak var connection: PunktfunkConnection?
|
||||||
private let debounce: TimeInterval
|
private let debounce: TimeInterval
|
||||||
private let minSpacing: TimeInterval
|
private let minSpacing: TimeInterval
|
||||||
|
/// Render-scale multiplier applied to the live window size before it's requested, so the
|
||||||
|
/// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the
|
||||||
|
/// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling.
|
||||||
|
private let renderScale: Double
|
||||||
|
private let maxDimension: Int
|
||||||
private var enabled: Bool
|
private var enabled: Bool
|
||||||
|
|
||||||
private var work: DispatchWorkItem?
|
private var work: DispatchWorkItem?
|
||||||
@@ -74,15 +80,28 @@ public final class MatchWindowFollower {
|
|||||||
public init(
|
public init(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
enabled: Bool,
|
enabled: Bool,
|
||||||
|
renderScale: Double = 1.0,
|
||||||
|
maxDimension: Int = 8192,
|
||||||
debounce: TimeInterval = 0.4,
|
debounce: TimeInterval = 0.4,
|
||||||
minSpacing: TimeInterval = 1.0
|
minSpacing: TimeInterval = 1.0
|
||||||
) {
|
) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
|
self.renderScale = RenderScale.sanitize(renderScale)
|
||||||
|
self.maxDimension = maxDimension
|
||||||
self.debounce = debounce
|
self.debounce = debounce
|
||||||
self.minSpacing = minSpacing
|
self.minSpacing = minSpacing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The host-valid mode for a live window size: the window's physical pixels × the render scale,
|
||||||
|
/// aspect-preserved, even, and clamped to the codec ceiling — the match-window twin of
|
||||||
|
/// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0.
|
||||||
|
private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
|
||||||
|
RenderScale.apply(
|
||||||
|
baseWidth: widthPx, baseHeight: heightPx,
|
||||||
|
scale: renderScale, maxDimension: maxDimension)
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
/// Turn following on/off live (a mid-session settings change; off cancels a pending request).
|
||||||
public func setEnabled(_ on: Bool) {
|
public func setEnabled(_ on: Bool) {
|
||||||
enabled = on
|
enabled = on
|
||||||
@@ -109,7 +128,7 @@ public final class MatchWindowFollower {
|
|||||||
/// mode yet → nothing to compare against, skip.
|
/// mode yet → nothing to compare against, skip.
|
||||||
private func reportSteering(widthPx: Int, heightPx: Int) {
|
private func reportSteering(widthPx: Int, heightPx: Int) {
|
||||||
guard let connection else { return }
|
guard let connection else { return }
|
||||||
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx)
|
let target = targetMode(widthPx: widthPx, heightPx: heightPx)
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
guard mode.width > 0, mode.height > 0 else { return }
|
guard mode.width > 0, mode.height > 0 else { return }
|
||||||
if target.width == mode.width, target.height == mode.height {
|
if target.width == mode.width, target.height == mode.height {
|
||||||
@@ -137,7 +156,7 @@ public final class MatchWindowFollower {
|
|||||||
schedule()
|
schedule()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height)
|
let target = targetMode(widthPx: size.width, heightPx: size.height)
|
||||||
let mode = connection.currentMode()
|
let mode = connection.currentMode()
|
||||||
pendingSize = nil
|
pendingSize = nil
|
||||||
guard let req = MatchWindow.request(
|
guard let req = MatchWindow.request(
|
||||||
|
|||||||
@@ -621,6 +621,12 @@ public final class StreamLayerView: NSView {
|
|||||||
guard let self, self.window?.isKeyWindow == true else { return }
|
guard let self, self.window?.isKeyWindow == true else { return }
|
||||||
self.onDisconnectRequest?()
|
self.onDisconnectRequest?()
|
||||||
}
|
}
|
||||||
|
capture.onToggleFullscreen = { [weak self] in
|
||||||
|
// App-level window action: post to the key window's FullscreenController (same routing as
|
||||||
|
// the Stream menu's ⌃⌘F item, so captured and released states hit one code path).
|
||||||
|
guard self?.window?.isKeyWindow == true else { return }
|
||||||
|
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||||
|
}
|
||||||
capture.onCycleStats = { [weak self] in
|
capture.onCycleStats = { [weak self] in
|
||||||
guard self?.window?.isKeyWindow == true else { return }
|
guard self?.window?.isKeyWindow == true else { return }
|
||||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||||
@@ -671,7 +677,10 @@ public final class StreamLayerView: NSView {
|
|||||||
// default keeps the explicit mode.
|
// default keeps the explicit mode.
|
||||||
let follower = MatchWindowFollower(
|
let follower = MatchWindowFollower(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||||
|
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||||
|
maxDimension: RenderScale.maxDimension(
|
||||||
|
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||||
matchFollower = follower
|
matchFollower = follower
|
||||||
layoutPresenter()
|
layoutPresenter()
|
||||||
|
|||||||
@@ -385,7 +385,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
|||||||
// default keeps the explicit mode.
|
// default keeps the explicit mode.
|
||||||
let follower = MatchWindowFollower(
|
let follower = MatchWindowFollower(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
|
||||||
|
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
|
||||||
|
maxDimension: RenderScale.maxDimension(
|
||||||
|
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
|
||||||
follower.onResizeTarget = onResizeTarget
|
follower.onResizeTarget = onResizeTarget
|
||||||
matchFollower = follower
|
matchFollower = follower
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Location-based modifier mapping — which Windows VK each PHYSICAL modifier position forwards to
|
||||||
|
// the host. A Mac keyboard's bottom row is `⌃ Control / ⌥ Option / ⌘ Command / space`; a Windows
|
||||||
|
// keyboard's is `Ctrl / ⊞ Super / Alt / space`. So the key NEAREST the space bar is Command on a
|
||||||
|
// Mac but Alt on Windows, and the next one out is Option on a Mac but the Windows key. A user who
|
||||||
|
// grew up on Windows reaches for Alt where the Mac has Command; this setting lets them get that
|
||||||
|
// muscle memory back WITHOUT relabelling keycaps — it remaps by physical position, not by a blunt
|
||||||
|
// "swap these two VKs" that would also drag Control/Shift or lose the left/right distinction.
|
||||||
|
//
|
||||||
|
// The model: keep the physical detection (which side, which key) exactly as the OS reports it, and
|
||||||
|
// swap only the Alt-vs-Super ROLE between the Option and Command keys, per side. So under `.windows`
|
||||||
|
// the ⌘ position emits Alt (VK_L/RMENU) and the ⌥ position emits the Windows key (VK_L/RWIN), while
|
||||||
|
// left stays left and right stays right. Control and Shift are in the same place on both keyboards,
|
||||||
|
// so they never move. Lives in PunktfunkShared because both the Settings UI (PunktfunkClient) and
|
||||||
|
// the wire-boundary remap (`InputCapture.applyModifierLayout`, PunktfunkKit) resolve it.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// How the physical ⌥ Option / ⌘ Command keys map to host modifiers. The raw values are stable on
|
||||||
|
/// disk — rename the cases freely, never the strings.
|
||||||
|
public enum ModifierLayout: String, CaseIterable, Sendable {
|
||||||
|
/// Apple positions (default): ⌥ Option → Alt, ⌘ Command → Super/Windows. The current behaviour.
|
||||||
|
case mac
|
||||||
|
/// Windows positions: the key nearest the space bar (⌘ Command) → Alt, the next one (⌥ Option)
|
||||||
|
/// → the Windows/Super key. Side (left/right) is preserved.
|
||||||
|
case windows
|
||||||
|
|
||||||
|
/// User-facing label (Settings picker).
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .mac: return "Mac (⌥ Alt · ⌘ Super)"
|
||||||
|
case .windows: return "Windows (⌘ Alt · ⌥ Super)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-line explanation for the setting's footer.
|
||||||
|
public var detail: String {
|
||||||
|
switch self {
|
||||||
|
case .mac:
|
||||||
|
return "The ⌥ Option key sends Alt and the ⌘ Command key sends the Windows key — the Apple layout."
|
||||||
|
case .windows:
|
||||||
|
return "The key nearest the space bar sends Alt and the next one sends the Windows key, matching a PC keyboard. Client shortcuts (⌘⎋ and friends) still use the physical ⌘ key."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted layout (default `.mac` when unset).
|
||||||
|
public static var current: ModifierLayout {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
|
||||||
|
return .mac
|
||||||
|
}
|
||||||
|
return ModifierLayout(rawValue: raw) ?? .mac
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Pins the location-based modifier remap (`InputCapture.applyModifierLayout`) — the wire-boundary
|
||||||
|
/// swap that relocates the Alt/Super role between the physical ⌥/⌘ keys per side, without touching
|
||||||
|
/// Control/Shift or the physical detection upstream.
|
||||||
|
final class ModifierLayoutMappingTests: XCTestCase {
|
||||||
|
// The four physical modifier VKs the remap can touch, and everything else it must not.
|
||||||
|
private let lWin: UInt32 = 0x5B, rWin: UInt32 = 0x5C
|
||||||
|
private let lAlt: UInt32 = 0xA4, rAlt: UInt32 = 0xA5
|
||||||
|
|
||||||
|
func testMacLayoutIsIdentity() {
|
||||||
|
for vk: UInt32 in [lWin, rWin, lAlt, rAlt, 0xA0, 0xA2, 0x41, 0x1B] {
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .mac), vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowsLayoutSwapsAltAndSuperPerSide() {
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(lWin, .windows), lAlt) // L ⌘ → L Alt
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(rWin, .windows), rAlt) // R ⌘ → R Alt
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(lAlt, .windows), lWin) // L ⌥ → L Win
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(rAlt, .windows), rWin) // R ⌥ → R Win
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowsLayoutKeepsSideNeverCrossesLeftRight() {
|
||||||
|
// A left key never becomes a right VK or vice-versa.
|
||||||
|
XCTAssertNotEqual(InputCapture.applyModifierLayout(lWin, .windows), rAlt)
|
||||||
|
XCTAssertNotEqual(InputCapture.applyModifierLayout(rWin, .windows), lAlt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testControlShiftAndRegularKeysNeverMove() {
|
||||||
|
for vk: UInt32 in [
|
||||||
|
0xA0, 0xA1, // L/R Shift
|
||||||
|
0xA2, 0xA3, // L/R Control
|
||||||
|
0x41, 0x5A, // A, Z
|
||||||
|
0x1B, 0x0D, // Esc, Return
|
||||||
|
] {
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .windows), vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWindowsRemapIsItsOwnInverse() {
|
||||||
|
// Down-remapped then the same key up-remapped land on the same host VK — no stuck modifier.
|
||||||
|
for vk: UInt32 in [lWin, rWin, lAlt, rAlt] {
|
||||||
|
let once = InputCapture.applyModifierLayout(vk, .windows)
|
||||||
|
XCTAssertEqual(InputCapture.applyModifierLayout(once, .windows), vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
import PunktfunkShared
|
||||||
|
|
||||||
|
/// Pins the render-scale geometry (`RenderScale`) — the multiply → aspect-preserve → even-floor →
|
||||||
|
/// codec-clamp that turns the `renderScale` setting into a host-valid `Mode`, plus the label/clamp
|
||||||
|
/// helpers the UI and the connect share.
|
||||||
|
final class RenderScaleTests: XCTestCase {
|
||||||
|
func testSanitizeClampsToRangeAndDefaultsMissing() {
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(0), 1.0) // absent / zero → Native
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(-2), 1.0) // nonsense → Native
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(0.1), 0.5) // below the floor
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(9), 4.0) // above the ceiling
|
||||||
|
XCTAssertEqual(RenderScale.sanitize(1.5), 1.5) // in range, untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMaxDimensionIsCodecAware() {
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "h264"), 4096)
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "hevc"), 8192)
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "av1"), 8192)
|
||||||
|
XCTAssertEqual(RenderScale.maxDimension(codec: "auto"), 8192)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNativeScaleIsIdentity() {
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 1.0, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width, 1920)
|
||||||
|
XCTAssertEqual(m.height, 1080)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupersampleDoubles() {
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 2.0, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width, 3840)
|
||||||
|
XCTAssertEqual(m.height, 2160)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnderRenderHalves() {
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 0.5, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width, 960)
|
||||||
|
XCTAssertEqual(m.height, 540)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResultsAreAlwaysEven() {
|
||||||
|
// 1280×720 × 1.5 = 1920×1080 (already even); 1366×768 × 1.5 = 2049×1152 → 2048×1152.
|
||||||
|
let m = RenderScale.apply(baseWidth: 1366, baseHeight: 768, scale: 1.5, maxDimension: 8192)
|
||||||
|
XCTAssertEqual(m.width % 2, 0)
|
||||||
|
XCTAssertEqual(m.height % 2, 0)
|
||||||
|
XCTAssertEqual(m.width, 2048)
|
||||||
|
XCTAssertEqual(m.height, 1152)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOverCeilingClampsUniformlyPreservingAspect() {
|
||||||
|
// 4K × 4 would be 15360×8640; both axes exceed 8192, so the LARGER (width) lands on the cap
|
||||||
|
// and the ratio is kept (16:9 → 8192×4608, even).
|
||||||
|
let m = RenderScale.apply(baseWidth: 3840, baseHeight: 2160, scale: 4.0, maxDimension: 8192)
|
||||||
|
XCTAssertLessThanOrEqual(m.width, 8192)
|
||||||
|
XCTAssertLessThanOrEqual(m.height, 8192)
|
||||||
|
XCTAssertEqual(m.width, 8192)
|
||||||
|
XCTAssertEqual(m.height, 4608)
|
||||||
|
// 16:9 preserved to within the even-floor rounding.
|
||||||
|
XCTAssertEqual(Double(m.width) / Double(m.height), 16.0 / 9.0, accuracy: 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testH264CeilingIsTighter() {
|
||||||
|
// 1080p × 4 = 7680×4320; under H.264's 4096 wall the width clamps to 4096, aspect kept.
|
||||||
|
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 4.0, maxDimension: 4096)
|
||||||
|
XCTAssertEqual(m.width, 4096)
|
||||||
|
XCTAssertEqual(m.height, 2304)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMinimumFloorHonoured() {
|
||||||
|
// A tiny base under a small scale can't fall below 320×200.
|
||||||
|
let m = RenderScale.apply(baseWidth: 400, baseHeight: 300, scale: 0.5, maxDimension: 8192)
|
||||||
|
XCTAssertGreaterThanOrEqual(m.width, 320)
|
||||||
|
XCTAssertGreaterThanOrEqual(m.height, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLabels() {
|
||||||
|
XCTAssertEqual(RenderScale.label(1.0), "Native (1×)")
|
||||||
|
XCTAssertEqual(RenderScale.label(2.0), "2× · supersample")
|
||||||
|
XCTAssertEqual(RenderScale.label(0.5), "0.5×")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -748,6 +748,9 @@ mod pipewire {
|
|||||||
/// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over
|
/// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over
|
||||||
/// `nv12` (a 4:4:4 session must never subsample).
|
/// `nv12` (a 4:4:4 session must never subsample).
|
||||||
yuv444: bool,
|
yuv444: bool,
|
||||||
|
/// The LINEAR (gamescope) NV12 compute CSC failed once — RGB for the rest of the stream
|
||||||
|
/// (T2.5b's per-stream fallback latch; cleared by the next stream's fresh `Ud`).
|
||||||
|
linear_nv12_failed: bool,
|
||||||
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
|
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
|
||||||
dbg_log_n: u64,
|
dbg_log_n: u64,
|
||||||
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
|
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
|
||||||
@@ -1352,15 +1355,17 @@ mod pipewire {
|
|||||||
// sample LINEAR).
|
// sample LINEAR).
|
||||||
let modifier = (ud.modifier != 0).then_some(ud.modifier);
|
let modifier = (ud.modifier != 0).then_some(ud.modifier);
|
||||||
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
|
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
|
||||||
// GPU converts only on the tiled EGL/GL path (`modifier.is_some()`): a 4:4:4
|
// GPU converts: a 4:4:4 session gets the planar-YUV444 convert on the tiled
|
||||||
// session gets the planar-YUV444 convert (full chroma, takes precedence over
|
// EGL/GL path (full chroma, takes precedence over NV12 — 4:4:4 must never
|
||||||
// NV12 — 4:4:4 must never subsample), otherwise `PUNKTFUNK_NV12` gets NV12 —
|
// subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — tiled via the EGL/GL
|
||||||
// both feed NVENC native YUV so it skips its internal RGB→YUV CSC. The
|
// blit, LINEAR/gamescope via the Vulkan bridge's compute CSC (latency plan
|
||||||
// LINEAR/Vulkan (gamescope) path stays RGB — its converts aren't wired here;
|
// T2.5b) — so NVENC encodes native YUV and skips its internal RGB→YUV CSC on
|
||||||
// a 4:4:4 session on LINEAR frames falls to the encoder's clear-error path
|
// the contended SM. A 4:4:4 session on LINEAR frames has no convert and
|
||||||
// (`want_444` with an RGB CUDA payload) rather than silently subsampling.
|
// stays RGB, falling to the encoder's clear-error path (`want_444` with an
|
||||||
|
// RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert
|
||||||
|
// failure latches RGB for the stream (mid-frame fallback, no drop).
|
||||||
let yuv444 = ud.yuv444 && modifier.is_some();
|
let yuv444 = ud.yuv444 && modifier.is_some();
|
||||||
let nv12 = ud.nv12 && !yuv444 && modifier.is_some();
|
let mut nv12 = ud.nv12 && !ud.yuv444;
|
||||||
let imported = if let Some(m) = modifier {
|
let imported = if let Some(m) = modifier {
|
||||||
if yuv444 {
|
if yuv444 {
|
||||||
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
|
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
|
||||||
@@ -1369,7 +1374,20 @@ mod pipewire {
|
|||||||
} else {
|
} else {
|
||||||
importer.import(&plane, w as u32, h as u32, fourcc, Some(m))
|
importer.import(&plane, w as u32, h as u32, fourcc, Some(m))
|
||||||
}
|
}
|
||||||
|
} else if nv12 && !ud.linear_nv12_failed {
|
||||||
|
match importer.import_linear_nv12(&plane, w as u32, h as u32) {
|
||||||
|
Ok(buf) => Ok(buf),
|
||||||
|
Err(e) => {
|
||||||
|
ud.linear_nv12_failed = true;
|
||||||
|
nv12 = false;
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"LINEAR NV12 compute CSC failed — RGB for the rest of this \
|
||||||
|
stream (NVENC does the CSC internally)");
|
||||||
|
importer.import_linear(&plane, w as u32, h as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
nv12 = false;
|
||||||
importer.import_linear(&plane, w as u32, h as u32)
|
importer.import_linear(&plane, w as u32, h as u32)
|
||||||
};
|
};
|
||||||
match imported {
|
match imported {
|
||||||
@@ -1742,6 +1760,7 @@ mod pipewire {
|
|||||||
vaapi_passthrough,
|
vaapi_passthrough,
|
||||||
nv12: pf_zerocopy::nv12_enabled(),
|
nv12: pf_zerocopy::nv12_enabled(),
|
||||||
yuv444: want_444,
|
yuv444: want_444,
|
||||||
|
linear_nv12_failed: false,
|
||||||
dbg_log_n: 0,
|
dbg_log_n: 0,
|
||||||
cursor: CursorState::default(),
|
cursor: CursorState::default(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ mdns-sd = "0.20"
|
|||||||
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
# `ureq` is small + sync (the host uses it too) and its rustls unifies with the
|
||||||
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
# workspace's (quinn's) 0.23; the pinning verifier mirrors core's private `PinVerify`.
|
||||||
ureq = "2"
|
ureq = "2"
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ quinn = "0.11"
|
|||||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||||
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
|
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
|
||||||
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
|
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "bmp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "bmp", "jpeg", "gif"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
|
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ pub const WIRE_HTML: &str = "text/html";
|
|||||||
pub const WIRE_RTF: &str = "text/rtf";
|
pub const WIRE_RTF: &str = "text/rtf";
|
||||||
/// Wire MIME for a PNG image.
|
/// Wire MIME for a PNG image.
|
||||||
pub const WIRE_PNG: &str = "image/png";
|
pub const WIRE_PNG: &str = "image/png";
|
||||||
|
/// Wire MIME for a JPEG image — passed through VERBATIM when the source clipboard carries one
|
||||||
|
/// (no PNG transcode: a lossy original re-encoded lossless is pure bloat). [`WIRE_PNG`] remains
|
||||||
|
/// the universal fallback every peer must accept; JPEG/GIF are richer options beside it.
|
||||||
|
pub const WIRE_JPEG: &str = "image/jpeg";
|
||||||
|
/// Wire MIME for a GIF image — verbatim pass-through preserves animation end to end.
|
||||||
|
pub const WIRE_GIF: &str = "image/gif";
|
||||||
|
|
||||||
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
|
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
|
||||||
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
|
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
|
||||||
@@ -248,6 +254,8 @@ pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
|
|||||||
"text/html" => Some(WIRE_HTML),
|
"text/html" => Some(WIRE_HTML),
|
||||||
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
|
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
|
||||||
"image/png" => Some(WIRE_PNG),
|
"image/png" => Some(WIRE_PNG),
|
||||||
|
"image/jpeg" => Some(WIRE_JPEG),
|
||||||
|
"image/gif" => Some(WIRE_GIF),
|
||||||
_ => match base {
|
_ => match base {
|
||||||
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
|
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -270,6 +278,8 @@ pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
|
|||||||
WIRE_HTML => &["text/html"],
|
WIRE_HTML => &["text/html"],
|
||||||
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
|
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
|
||||||
WIRE_PNG => &["image/png"],
|
WIRE_PNG => &["image/png"],
|
||||||
|
WIRE_JPEG => &["image/jpeg"],
|
||||||
|
WIRE_GIF => &["image/gif"],
|
||||||
_ => &[],
|
_ => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,6 +340,8 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
|
|||||||
push("text/rtf");
|
push("text/rtf");
|
||||||
}
|
}
|
||||||
WIRE_PNG => push("image/png"),
|
WIRE_PNG => push("image/png"),
|
||||||
|
WIRE_JPEG => push("image/jpeg"),
|
||||||
|
WIRE_GIF => push("image/gif"),
|
||||||
other => push(other),
|
other => push(other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,10 +367,13 @@ mod tests {
|
|||||||
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
|
assert_eq!(wayland_to_wire("text/html"), Some(WIRE_HTML));
|
||||||
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
|
assert_eq!(wayland_to_wire("application/rtf"), Some(WIRE_RTF));
|
||||||
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
|
assert_eq!(wayland_to_wire("image/png"), Some(WIRE_PNG));
|
||||||
|
// Original image formats now map to their own wire kinds (verbatim pass-through).
|
||||||
|
assert_eq!(wayland_to_wire("image/jpeg"), Some(WIRE_JPEG));
|
||||||
|
assert_eq!(wayland_to_wire("image/gif"), Some(WIRE_GIF));
|
||||||
// Internal targets and unsupported formats are dropped.
|
// Internal targets and unsupported formats are dropped.
|
||||||
assert_eq!(wayland_to_wire("TARGETS"), None);
|
assert_eq!(wayland_to_wire("TARGETS"), None);
|
||||||
assert_eq!(wayland_to_wire("TIMESTAMP"), None);
|
assert_eq!(wayland_to_wire("TIMESTAMP"), None);
|
||||||
assert_eq!(wayland_to_wire("image/jpeg"), None);
|
assert_eq!(wayland_to_wire("image/webp"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ use ::windows::Win32::UI::WindowsAndMessaging::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::winfmt;
|
use super::winfmt;
|
||||||
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT};
|
use super::{
|
||||||
|
ClipEvent, PasteResponder, WIRE_GIF, WIRE_HTML, WIRE_JPEG, WIRE_PNG, WIRE_RTF, WIRE_TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
|
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
|
||||||
const WM_APP_CMD: u32 = WM_APP + 1;
|
const WM_APP_CMD: u32 = WM_APP + 1;
|
||||||
@@ -98,6 +100,13 @@ struct WinClip {
|
|||||||
fmt_html: u32,
|
fmt_html: u32,
|
||||||
fmt_rtf: u32,
|
fmt_rtf: u32,
|
||||||
fmt_png: u32,
|
fmt_png: u32,
|
||||||
|
/// Registered `"JFIF"` — the conventional raw-JPEG clipboard format (Office/browsers).
|
||||||
|
fmt_jfif: u32,
|
||||||
|
/// Registered `"GIF"` — raw GIF bytes (animation preserved by verbatim pass-through).
|
||||||
|
fmt_gif: u32,
|
||||||
|
/// The wire MIMEs of the offer currently promised via delayed rendering — `WM_RENDERFORMAT`
|
||||||
|
/// for the synthesized `CF_DIB` picks the richest image kind among them to fetch + convert.
|
||||||
|
offered_wires: RefCell<Vec<String>>,
|
||||||
/// Our own message window — used for the owner-check and clipboard opens.
|
/// Our own message window — used for the owner-check and clipboard opens.
|
||||||
own_hwnd: HWND,
|
own_hwnd: HWND,
|
||||||
}
|
}
|
||||||
@@ -137,6 +146,15 @@ impl WinClip {
|
|||||||
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
|
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
|
||||||
out.push(WIRE_PNG.to_string());
|
out.push(WIRE_PNG.to_string());
|
||||||
}
|
}
|
||||||
|
// Original lossy/animated formats offered VERBATIM beside the PNG floor — the client
|
||||||
|
// picks the richest kind it can place, so a copied JPEG never balloons into PNG and a
|
||||||
|
// GIF keeps its animation.
|
||||||
|
if avail(self.fmt_jfif) {
|
||||||
|
out.push(WIRE_JPEG.to_string());
|
||||||
|
}
|
||||||
|
if avail(self.fmt_gif) {
|
||||||
|
out.push(WIRE_GIF.to_string());
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +210,7 @@ impl WinClip {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
*self.offered.borrow_mut() = fmts;
|
*self.offered.borrow_mut() = fmts;
|
||||||
|
*self.offered_wires.borrow_mut() = wire.to_vec();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the selection we own (empty the clipboard iff we're still its owner).
|
/// Drop the selection we own (empty the clipboard iff we're still its owner).
|
||||||
@@ -265,8 +284,23 @@ impl WinClip {
|
|||||||
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
|
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
|
||||||
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
|
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
|
||||||
fn on_render_format(&self, fmt: u32) {
|
fn on_render_format(&self, fmt: u32) {
|
||||||
let Some(wire) = self.wire_for_format(fmt) else {
|
// The synthesized CF_DIB promise has no wire kind of its own: fetch the richest image
|
||||||
return;
|
// kind the client offered (PNG first — lossless with alpha — then JPEG, then GIF's first
|
||||||
|
// frame) and convert. Every other format maps 1:1.
|
||||||
|
let wire: &str = if fmt == CF_DIB.0 as u32 {
|
||||||
|
let offered = self.offered_wires.borrow();
|
||||||
|
match [WIRE_PNG, WIRE_JPEG, WIRE_GIF]
|
||||||
|
.into_iter()
|
||||||
|
.find(|w| offered.iter().any(|o| o == w))
|
||||||
|
{
|
||||||
|
Some(w) => w,
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match self.wire_for_format(fmt) {
|
||||||
|
Some(w) => w,
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||||
let ev = ClipEvent::Paste {
|
let ev = ClipEvent::Paste {
|
||||||
@@ -281,9 +315,9 @@ impl WinClip {
|
|||||||
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
|
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
|
||||||
};
|
};
|
||||||
let win_bytes = if fmt == CF_DIB.0 as u32 {
|
let win_bytes = if fmt == CF_DIB.0 as u32 {
|
||||||
// The app asked for a bitmap: the client served PNG — convert. A PNG that doesn't
|
// The app asked for a bitmap: convert whatever image kind the client served. Bytes
|
||||||
// decode leaves the format unrendered (empty paste), matching the timeout path.
|
// that don't decode leave the format unrendered (empty paste), like the timeout path.
|
||||||
match winfmt::png_to_dib(&bytes) {
|
match winfmt::image_to_dib(&bytes) {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return,
|
None => return,
|
||||||
}
|
}
|
||||||
@@ -310,6 +344,8 @@ impl WinClip {
|
|||||||
WIRE_HTML => Some(self.fmt_html),
|
WIRE_HTML => Some(self.fmt_html),
|
||||||
WIRE_RTF => Some(self.fmt_rtf),
|
WIRE_RTF => Some(self.fmt_rtf),
|
||||||
WIRE_PNG => Some(self.fmt_png),
|
WIRE_PNG => Some(self.fmt_png),
|
||||||
|
WIRE_JPEG => Some(self.fmt_jfif),
|
||||||
|
WIRE_GIF => Some(self.fmt_gif),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,8 +358,12 @@ impl WinClip {
|
|||||||
Some(WIRE_HTML)
|
Some(WIRE_HTML)
|
||||||
} else if fmt == self.fmt_rtf {
|
} else if fmt == self.fmt_rtf {
|
||||||
Some(WIRE_RTF)
|
Some(WIRE_RTF)
|
||||||
} else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 {
|
} else if fmt == self.fmt_png {
|
||||||
Some(WIRE_PNG)
|
Some(WIRE_PNG)
|
||||||
|
} else if fmt == self.fmt_jfif {
|
||||||
|
Some(WIRE_JPEG)
|
||||||
|
} else if fmt == self.fmt_gif {
|
||||||
|
Some(WIRE_GIF)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -340,9 +380,12 @@ impl WinClip {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
|
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
|
||||||
// ask for the bitmap family, not the registered "PNG"; Windows synthesizes
|
// ask for the bitmap family, not the registered image formats; Windows synthesizes
|
||||||
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand.
|
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` fetches the richest
|
||||||
if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) {
|
// offered image kind and converts on demand.
|
||||||
|
if matches!(w.as_str(), WIRE_PNG | WIRE_JPEG | WIRE_GIF)
|
||||||
|
&& !out.contains(&(CF_DIB.0 as u32))
|
||||||
|
{
|
||||||
out.push(CF_DIB.0 as u32);
|
out.push(CF_DIB.0 as u32);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,12 +419,18 @@ impl WindowsClipboard {
|
|||||||
let fmt_html = register_format(w!("HTML Format"))?;
|
let fmt_html = register_format(w!("HTML Format"))?;
|
||||||
let fmt_rtf = register_format(w!("Rich Text Format"))?;
|
let fmt_rtf = register_format(w!("Rich Text Format"))?;
|
||||||
let fmt_png = register_format(w!("PNG"))?;
|
let fmt_png = register_format(w!("PNG"))?;
|
||||||
|
let fmt_jfif = register_format(w!("JFIF"))?;
|
||||||
|
let fmt_gif = register_format(w!("GIF"))?;
|
||||||
|
|
||||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
|
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
|
||||||
let cw = Arc::clone(¤t_wire);
|
let cw = Arc::clone(¤t_wire);
|
||||||
let join = std::thread::Builder::new()
|
let join = std::thread::Builder::new()
|
||||||
.name("punktfunk-clipboard-win".into())
|
.name("punktfunk-clipboard-win".into())
|
||||||
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx))
|
.spawn(move || {
|
||||||
|
pump_thread(
|
||||||
|
clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, fmt_jfif, fmt_gif, ready_tx,
|
||||||
|
)
|
||||||
|
})
|
||||||
.context("spawn windows clipboard thread")?;
|
.context("spawn windows clipboard thread")?;
|
||||||
|
|
||||||
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
|
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
|
||||||
@@ -573,6 +622,7 @@ fn create_window() -> anyhow::Result<HWND> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
|
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn pump_thread(
|
fn pump_thread(
|
||||||
clip_tx: ClipTx,
|
clip_tx: ClipTx,
|
||||||
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
|
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
|
||||||
@@ -580,6 +630,8 @@ fn pump_thread(
|
|||||||
fmt_html: u32,
|
fmt_html: u32,
|
||||||
fmt_rtf: u32,
|
fmt_rtf: u32,
|
||||||
fmt_png: u32,
|
fmt_png: u32,
|
||||||
|
fmt_jfif: u32,
|
||||||
|
fmt_gif: u32,
|
||||||
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
|
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
|
||||||
) {
|
) {
|
||||||
let hwnd = match create_window() {
|
let hwnd = match create_window() {
|
||||||
@@ -598,9 +650,12 @@ fn pump_thread(
|
|||||||
current_wire,
|
current_wire,
|
||||||
cmd_rx: RefCell::new(cmd_rx),
|
cmd_rx: RefCell::new(cmd_rx),
|
||||||
offered: RefCell::new(Vec::new()),
|
offered: RefCell::new(Vec::new()),
|
||||||
|
offered_wires: RefCell::new(Vec::new()),
|
||||||
fmt_html,
|
fmt_html,
|
||||||
fmt_rtf,
|
fmt_rtf,
|
||||||
fmt_png,
|
fmt_png,
|
||||||
|
fmt_jfif,
|
||||||
|
fmt_gif,
|
||||||
own_hwnd: hwnd,
|
own_hwnd: hwnd,
|
||||||
});
|
});
|
||||||
let ptr = Box::into_raw(state);
|
let ptr = Box::into_raw(state);
|
||||||
|
|||||||
@@ -158,6 +158,89 @@ fn strip_trailing_nul(b: &[u8]) -> &[u8] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes
|
||||||
|
// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when
|
||||||
|
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
|
||||||
|
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
|
||||||
|
|
||||||
|
/// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL
|
||||||
|
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
|
||||||
|
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
|
||||||
|
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let img = image::load_from_memory(bytes).ok()?;
|
||||||
|
let rgba = img.to_rgba8();
|
||||||
|
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
||||||
|
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(40 + w * h * 4);
|
||||||
|
// BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows.
|
||||||
|
out.extend_from_slice(&40u32.to_le_bytes()); // biSize
|
||||||
|
out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
|
||||||
|
out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up)
|
||||||
|
out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
||||||
|
out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
|
||||||
|
out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
|
||||||
|
out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage
|
||||||
|
out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant
|
||||||
|
// Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned).
|
||||||
|
for row in rgba.rows().rev() {
|
||||||
|
for px in row {
|
||||||
|
let [r, g, b, a] = px.0;
|
||||||
|
out.extend_from_slice(&[b, g, r, a]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects
|
||||||
|
/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on
|
||||||
|
/// any malformed input — the caller declines the fetch.
|
||||||
|
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
if dib.len() < 40 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize;
|
||||||
|
if hdr_size < 40 || hdr_size > dib.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize;
|
||||||
|
let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
|
||||||
|
let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize;
|
||||||
|
// Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels.
|
||||||
|
let palette = if bit_count <= 8 {
|
||||||
|
(if clr_used != 0 {
|
||||||
|
clr_used
|
||||||
|
} else {
|
||||||
|
1 << bit_count
|
||||||
|
}) * 4
|
||||||
|
} else if compression == 3 {
|
||||||
|
// BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER —
|
||||||
|
// V4/V5 headers already contain the masks within hdr_size).
|
||||||
|
if hdr_size == 40 {
|
||||||
|
12
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let pixel_offset = 14 + hdr_size + palette;
|
||||||
|
let mut bmp = Vec::with_capacity(14 + dib.len());
|
||||||
|
bmp.extend_from_slice(b"BM");
|
||||||
|
bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
|
||||||
|
bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
||||||
|
bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
|
||||||
|
bmp.extend_from_slice(dib);
|
||||||
|
let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;
|
||||||
|
let mut png = Vec::new();
|
||||||
|
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.ok()?;
|
||||||
|
Some(png)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -265,7 +348,7 @@ mod tests {
|
|||||||
image::DynamicImage::ImageRgba8(img.clone())
|
image::DynamicImage::ImageRgba8(img.clone())
|
||||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let dib = png_to_dib(&png).expect("png -> dib");
|
let dib = image_to_dib(&png).expect("png -> dib");
|
||||||
// BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp.
|
// BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp.
|
||||||
assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40);
|
assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40);
|
||||||
assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3);
|
assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3);
|
||||||
@@ -282,85 +365,3 @@ mod tests {
|
|||||||
assert!(dib_to_png(&[0xFFu8; 200]).is_none());
|
assert!(dib_to_png(&[0xFFu8; 200]).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CF_DIB ↔ image/png ------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Most Windows apps speak the bitmap clipboard family (`CF_DIB`, from which Windows synthesizes
|
|
||||||
// `CF_BITMAP`/`CF_DIBV5`), not the registered `"PNG"` format — so images only interoperate when
|
|
||||||
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
|
|
||||||
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
|
|
||||||
|
|
||||||
/// PNG wire bytes → `CF_DIB` HGLOBAL bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up).
|
|
||||||
/// `None` when the PNG doesn't decode — the caller leaves the format unrendered (empty paste).
|
|
||||||
pub fn png_to_dib(png: &[u8]) -> Option<Vec<u8>> {
|
|
||||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?;
|
|
||||||
let rgba = img.to_rgba8();
|
|
||||||
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
|
||||||
if w == 0 || h == 0 || w > 32767 || h > 32767 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut out = Vec::with_capacity(40 + w * h * 4);
|
|
||||||
// BITMAPINFOHEADER: 32bpp BI_RGB needs no masks/palette; positive height = bottom-up rows.
|
|
||||||
out.extend_from_slice(&40u32.to_le_bytes()); // biSize
|
|
||||||
out.extend_from_slice(&(w as i32).to_le_bytes()); // biWidth
|
|
||||||
out.extend_from_slice(&(h as i32).to_le_bytes()); // biHeight (bottom-up)
|
|
||||||
out.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
|
||||||
out.extend_from_slice(&32u16.to_le_bytes()); // biBitCount
|
|
||||||
out.extend_from_slice(&0u32.to_le_bytes()); // biCompression = BI_RGB
|
|
||||||
out.extend_from_slice(&((w * h * 4) as u32).to_le_bytes()); // biSizeImage
|
|
||||||
out.extend_from_slice(&[0u8; 16]); // XPels/YPels/ClrUsed/ClrImportant
|
|
||||||
// Rows bottom-up, pixels BGRA (32bpp rows are already 4-byte aligned).
|
|
||||||
for row in rgba.rows().rev() {
|
|
||||||
for px in row {
|
|
||||||
let [r, g, b, a] = px.0;
|
|
||||||
out.extend_from_slice(&[b, g, r, a]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `CF_DIB` HGLOBAL bytes → PNG wire bytes: prepend the BITMAPFILEHEADER a BMP decoder expects
|
|
||||||
/// (computing the pixel offset from the header + palette/mask layout) and re-encode. `None` on
|
|
||||||
/// any malformed input — the caller declines the fetch.
|
|
||||||
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
|
||||||
if dib.len() < 40 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let hdr_size = u32::from_le_bytes(dib[0..4].try_into().ok()?) as usize;
|
|
||||||
if hdr_size < 40 || hdr_size > dib.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let bit_count = u16::from_le_bytes(dib[14..16].try_into().ok()?) as usize;
|
|
||||||
let compression = u32::from_le_bytes(dib[16..20].try_into().ok()?);
|
|
||||||
let clr_used = u32::from_le_bytes(dib[32..36].try_into().ok()?) as usize;
|
|
||||||
// Palette entries (≤8bpp) or BI_BITFIELDS masks follow the header before the pixels.
|
|
||||||
let palette = if bit_count <= 8 {
|
|
||||||
(if clr_used != 0 {
|
|
||||||
clr_used
|
|
||||||
} else {
|
|
||||||
1 << bit_count
|
|
||||||
}) * 4
|
|
||||||
} else if compression == 3 {
|
|
||||||
// BI_BITFIELDS: 3 DWORD masks (only when the header is a plain BITMAPINFOHEADER —
|
|
||||||
// V4/V5 headers already contain the masks within hdr_size).
|
|
||||||
if hdr_size == 40 {
|
|
||||||
12
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let pixel_offset = 14 + hdr_size + palette;
|
|
||||||
let mut bmp = Vec::with_capacity(14 + dib.len());
|
|
||||||
bmp.extend_from_slice(b"BM");
|
|
||||||
bmp.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
|
|
||||||
bmp.extend_from_slice(&0u32.to_le_bytes()); // reserved
|
|
||||||
bmp.extend_from_slice(&(pixel_offset as u32).to_le_bytes());
|
|
||||||
bmp.extend_from_slice(dib);
|
|
||||||
let img = image::load_from_memory_with_format(&bmp, image::ImageFormat::Bmp).ok()?;
|
|
||||||
let mut png = Vec::new();
|
|
||||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
|
||||||
.ok()?;
|
|
||||||
Some(png)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,9 +20,21 @@
|
|||||||
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
|
//! pointer-keyed, so registering a fresh pool pointer each frame would thrash it) — so it is
|
||||||
//! zero regression versus today; true zero-copy input registration is a follow-up.
|
//! zero regression versus today; true zero-copy input registration is a follow-up.
|
||||||
//!
|
//!
|
||||||
//! **Sync-only.** NVENC async mode (`enableEncodeAsync` + Win32 completion events) is Windows-only,
|
//! **Two-thread retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, the same opt-in knob as the Windows
|
||||||
//! so the whole two-thread async-retrieve subsystem of the Windows backend is absent here: `poll`
|
//! backend — gpu-contention plan §5.B, latency plan T2.2): NVENC *async mode*
|
||||||
//! does the blocking `lock_bitstream`, exactly like the libav path.
|
//! (`enableEncodeAsync` + completion events) is Windows-only, so the session here stays SYNC —
|
||||||
|
//! but the NVENC guide's threading model still applies: the main thread should only *submit*
|
||||||
|
//! while a secondary thread does the (blocking) `nvEncLockBitstream`. With the flag set, an
|
||||||
|
//! internal retrieve thread owns exactly that blocking lock (+ copy + unlock); `submit` returns
|
||||||
|
//! after `encode_picture` and `poll` drains finished AUs without blocking, so under a
|
||||||
|
//! GPU-saturating game completed frames queue instead of serializing capture on the scheduler
|
||||||
|
//! wait. All input-resource calls (register/map/unmap) and every other session call stay on the
|
||||||
|
//! encode thread. Backpressure: `submit` blocks on the oldest completion at
|
||||||
|
//! `PUNKTFUNK_NVENC_ASYNC_DEPTH` (default 4) in-flight encodes. Without the flag, `poll` does
|
||||||
|
//! the blocking `lock_bitstream` on the encode thread, exactly like the libav path (unchanged
|
||||||
|
//! default). Caveat shared with the sync path: a driver wedge that hangs `lock_bitstream` hangs
|
||||||
|
//! the retrieve thread the same way it would hang the encode thread today (Linux has no
|
||||||
|
//! event-timeout escape) — no regression, just no new watchdog either.
|
||||||
//!
|
//!
|
||||||
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
|
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise); compiles GPU-less and
|
||||||
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
|
//! starts driver-less (the `.so` resolves at runtime — on an AMD/Intel box [`try_api`] fails cleanly
|
||||||
@@ -42,6 +54,7 @@ use pf_zerocopy::cuda::{self, InputSurface};
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
|
||||||
|
|
||||||
@@ -205,11 +218,135 @@ fn load_api() -> std::result::Result<EncodeApi, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. The host
|
/// Output bitstream buffers = max in-flight encodes; equals the input-surface ring depth. Must
|
||||||
/// loop deep-pipelines (submits several frames before locking the oldest) so this must be ≥ the
|
/// stay ≥ the two-thread retrieve's in-flight cap ([`async_inflight_cap`], ≤ `POOL - 1`) so a
|
||||||
/// helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
|
/// bitstream/ring slot is never reused mid-encode.
|
||||||
const POOL: usize = 8;
|
const POOL: usize = 8;
|
||||||
|
|
||||||
|
/// Whether the operator asked for the two-thread retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy — the
|
||||||
|
/// SAME knob as the Windows backend, so one env drives the split on either host OS). Opt-in
|
||||||
|
/// until on-glass validated. Unlike Windows this changes NO session parameter (Linux stays sync
|
||||||
|
/// mode; only the blocking lock moves off the encode thread), so there is no async-rejecting
|
||||||
|
/// config to fail the open.
|
||||||
|
fn async_retrieve_requested() -> bool {
|
||||||
|
std::env::var("PUNKTFUNK_NVENC_ASYNC")
|
||||||
|
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max encodes in flight in two-thread mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
|
||||||
|
/// `2..=POOL-1` — a bitstream must never be reused mid-encode, and the input ring is the same
|
||||||
|
/// depth). Mirrors the Windows knob exactly.
|
||||||
|
fn async_inflight_cap() -> usize {
|
||||||
|
std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
|
.unwrap_or(4)
|
||||||
|
.clamp(2, POOL - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One in-flight encode handed to the retrieve thread: the output bitstream to (blocking-)lock.
|
||||||
|
/// Raw pointer travels as `usize` (a process-global driver handle; the thread is joined before
|
||||||
|
/// the session it belongs to is destroyed).
|
||||||
|
struct RetrieveJob {
|
||||||
|
bs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished retrieve: the locked-and-copied AU (or the retrieve-side error) for the oldest
|
||||||
|
/// in-flight bitstream. `bs` lets the encode thread cross-check FIFO pairing with `pending`.
|
||||||
|
struct RetrieveDone {
|
||||||
|
bs: usize,
|
||||||
|
result: std::result::Result<(Vec<u8>, bool), String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two-thread-retrieve runtime: the job channel feeding the retrieve thread, the completion
|
||||||
|
/// channel back, the thread handle (joined in `teardown` BEFORE the session is destroyed), and
|
||||||
|
/// AUs already absorbed by backpressure that `poll` hands out first.
|
||||||
|
struct AsyncRetrieve {
|
||||||
|
work_tx: Option<mpsc::SyncSender<RetrieveJob>>,
|
||||||
|
done_rx: mpsc::Receiver<RetrieveDone>,
|
||||||
|
join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
ready: VecDeque<EncodedFrame>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRetrieve {
|
||||||
|
fn spawn(enc: usize) -> Self {
|
||||||
|
let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
|
||||||
|
let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
|
||||||
|
let join = std::thread::Builder::new()
|
||||||
|
.name("pf-nvenc-out".into())
|
||||||
|
.spawn(move || retrieve_loop(enc, work_rx, done_tx))
|
||||||
|
.expect("spawn pf-nvenc-out");
|
||||||
|
AsyncRetrieve {
|
||||||
|
work_tx: Some(work_tx),
|
||||||
|
done_rx,
|
||||||
|
join: Some(join),
|
||||||
|
ready: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The retrieve-thread body (latency plan T2.2, the Linux half of gpu-contention §5.B): for each
|
||||||
|
/// submitted frame, BLOCKING-lock the bitstream (sync-mode `nvEncLockBitstream` returns when the
|
||||||
|
/// encode completes — the guide's sanctioned secondary-thread surface), copy the AU out, unlock,
|
||||||
|
/// and send it back. Exits when the job channel closes (teardown drops the sender and joins
|
||||||
|
/// BEFORE destroying the session, so `enc` and every `bs` outlive their uses here).
|
||||||
|
fn retrieve_loop(
|
||||||
|
enc: usize,
|
||||||
|
work_rx: mpsc::Receiver<RetrieveJob>,
|
||||||
|
done_tx: mpsc::Sender<RetrieveDone>,
|
||||||
|
) {
|
||||||
|
pf_frame::thread_qos::boost_thread_priority(false);
|
||||||
|
// The session is bound to the shared process-wide CUDA context; make it current here the
|
||||||
|
// same way the encode thread does before its own NVENC calls.
|
||||||
|
if let Err(e) = cuda::make_current() {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "pf-nvenc-out: cuCtxSetCurrent failed");
|
||||||
|
}
|
||||||
|
while let Ok(job) = work_rx.recv() {
|
||||||
|
// SAFETY: `job.bs` is one of the session's pool bitstreams a prior `encode_picture`
|
||||||
|
// targeted; both it and the session stay valid until `teardown`, which joins this thread
|
||||||
|
// first. `lock_bitstream` (version set, struct a live stack local for the synchronous
|
||||||
|
// call) BLOCKS until that encode finishes, then yields a CPU-readable
|
||||||
|
// `bitstreamBufferPtr`/`bitstreamSizeInBytes` valid until `unlock_bitstream`; the slice
|
||||||
|
// is copied (`to_vec`) before the unlock on the same buffer. Lock/unlock from a
|
||||||
|
// secondary thread while the encode thread submits is the NVENC guide's documented
|
||||||
|
// threading model.
|
||||||
|
let result = unsafe {
|
||||||
|
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||||
|
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||||
|
outputBitstream: job.bs as *mut c_void,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
|
||||||
|
Ok(()) => {
|
||||||
|
let data = std::slice::from_raw_parts(
|
||||||
|
lock.bitstreamBufferPtr as *const u8,
|
||||||
|
lock.bitstreamSizeInBytes as usize,
|
||||||
|
)
|
||||||
|
.to_vec();
|
||||||
|
let keyframe = matches!(
|
||||||
|
lock.pictureType,
|
||||||
|
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
|
||||||
|
| nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
|
||||||
|
);
|
||||||
|
let _ = (api().unlock_bitstream)(
|
||||||
|
enc as *mut c_void,
|
||||||
|
job.bs as nv::NV_ENC_OUTPUT_PTR,
|
||||||
|
);
|
||||||
|
Ok((data, keyframe))
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!(
|
||||||
|
"lock_bitstream (retrieve thread): {e:?} — {}",
|
||||||
|
nvenc_status::explain(e)
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
|
||||||
|
break; // encoder side gone (teardown drains us via join)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
|
/// The NVENC input buffer format for a captured `DeviceBuffer`'s layout. NV12/YUV444 are the zero-
|
||||||
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
|
/// copy worker's convert outputs; packed RGB (`ABGR`) is the fallback where NVENC does the internal
|
||||||
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
|
/// CSC. 10-bit is never produced on Linux today (Phase 5.1), so everything is 8-bit.
|
||||||
@@ -300,6 +437,9 @@ pub struct NvencCudaEncoder {
|
|||||||
/// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry
|
/// One-shot latch for [`diagnose_failed_open`](Self::diagnose_failed_open) so a rebuild-retry
|
||||||
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
|
/// burst (the session loop's bounded encoder resets) logs the diagnosis once, not per attempt.
|
||||||
diagnosed: bool,
|
diagnosed: bool,
|
||||||
|
/// The two-thread retrieve runtime (`PUNKTFUNK_NVENC_ASYNC`) — `None` in the default
|
||||||
|
/// single-thread mode and between sessions. Exists only `init_session`→`teardown`.
|
||||||
|
async_rt: Option<AsyncRetrieve>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
// SAFETY: the `!Send` fields are the raw NVENC session handle (`encoder`), the shared `CUcontext`
|
||||||
@@ -373,6 +513,7 @@ impl NvencCudaEncoder {
|
|||||||
custom_vbv: false,
|
custom_vbv: false,
|
||||||
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
|
||||||
last_rfi_range: None,
|
last_rfi_range: None,
|
||||||
|
async_rt: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,6 +522,15 @@ impl NvencCudaEncoder {
|
|||||||
if self.encoder.is_null() {
|
if self.encoder.is_null() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Stop the retrieve thread FIRST: close its job channel and join. Any in-flight blocking
|
||||||
|
// lock returns once its encode completes (≤ a frame time on a live driver), so the join
|
||||||
|
// is bounded; after it no other thread can touch the session the code below destroys.
|
||||||
|
if let Some(mut rt) = self.async_rt.take() {
|
||||||
|
rt.work_tx.take();
|
||||||
|
if let Some(j) = rt.join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
|
// Unmap any in-flight inputs, unregister every ring surface, destroy the bitstreams.
|
||||||
for (_, map, _, _) in &self.pending {
|
for (_, map, _, _) in &self.pending {
|
||||||
if !map.is_null() {
|
if !map.is_null() {
|
||||||
@@ -804,6 +954,15 @@ impl NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.inited = true;
|
self.inited = true;
|
||||||
|
// Two-thread retrieve (T2.2): spawn the lock thread against the live session. No
|
||||||
|
// session parameter differs — teardown/rebuild always stops it before destroy.
|
||||||
|
if async_retrieve_requested() {
|
||||||
|
self.async_rt = Some(AsyncRetrieve::spawn(self.encoder as usize));
|
||||||
|
tracing::info!(
|
||||||
|
depth = async_inflight_cap(),
|
||||||
|
"NVENC two-thread retrieve enabled (submit thread + blocking-lock thread)"
|
||||||
|
);
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
mode = %format_args!("{}x{}@{}", self.width, self.height, self.fps),
|
||||||
bit_depth = self.bit_depth,
|
bit_depth = self.bit_depth,
|
||||||
@@ -845,6 +1004,40 @@ impl NvencCudaEncoder {
|
|||||||
_ => cuda::copy_device_to_device(buf, base, pitch),
|
_ => cuda::copy_device_to_device(buf, base, pitch),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fold one retrieve-thread completion into `ready` (two-thread mode only): pop the oldest
|
||||||
|
/// in-flight entry, cross-check FIFO pairing, unmap its input HERE (the encode thread — the
|
||||||
|
/// retrieve thread never touches input resources), and queue the finished AU.
|
||||||
|
fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
|
||||||
|
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||||
|
bail!("NVENC retrieve: completion with no in-flight frame (pairing bug)");
|
||||||
|
};
|
||||||
|
if bs as usize != done.bs {
|
||||||
|
bail!("NVENC retrieve: completion out of order (pairing bug)");
|
||||||
|
}
|
||||||
|
// SAFETY: `map` is the mapped input `submit` recorded for exactly this now-completed
|
||||||
|
// encode; the session is live (`async_rt` exists only between `init_session` and
|
||||||
|
// `teardown`) and this runs on the encode thread — the single unmap here mirrors the
|
||||||
|
// sync path's poll-side unmap, exactly once per mapping.
|
||||||
|
unsafe {
|
||||||
|
if !map.is_null() {
|
||||||
|
let _ = (api().unmap_input_resource)(self.encoder, map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
self.async_rt
|
||||||
|
.as_mut()
|
||||||
|
.expect("absorb_done is only reachable in two-thread mode")
|
||||||
|
.ready
|
||||||
|
.push_back(EncodedFrame {
|
||||||
|
data,
|
||||||
|
pts_ns,
|
||||||
|
keyframe,
|
||||||
|
recovery_anchor: anchor,
|
||||||
|
chunk_aligned: false,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Encoder for NvencCudaEncoder {
|
impl Encoder for NvencCudaEncoder {
|
||||||
@@ -891,6 +1084,19 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
// output slot counter (`teardown` zeroes it), NOT `pts`: `submit_indexed` pins pts to the
|
// output slot counter (`teardown` zeroes it), NOT `pts`: `submit_indexed` pins pts to the
|
||||||
// wire frame index, non-zero on a mid-session rebuild's first frame.
|
// wire frame index, non-zero on a mid-session rebuild's first frame.
|
||||||
let opening = self.next == 0;
|
let opening = self.next == 0;
|
||||||
|
// Two-thread backpressure: never more than the cap in flight — block on the OLDEST
|
||||||
|
// completion first, absorbing its AU into `ready` for `poll`. Bounds the added latency
|
||||||
|
// exactly like the sync path's blocking poll, just `cap` deep instead of 1, and keeps
|
||||||
|
// this slot's bitstream/input surface free before they're reused below.
|
||||||
|
while self.async_rt.is_some() && self.pending.len() >= async_inflight_cap() {
|
||||||
|
let done = {
|
||||||
|
let rt = self.async_rt.as_mut().expect("checked in loop condition");
|
||||||
|
rt.done_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.map_err(|_| anyhow!("NVENC retrieve stalled (5s) — encoder wedged?"))?
|
||||||
|
};
|
||||||
|
self.absorb_done(done)?;
|
||||||
|
}
|
||||||
let slot = self.next % POOL;
|
let slot = self.next % POOL;
|
||||||
self.next += 1;
|
self.next += 1;
|
||||||
|
|
||||||
@@ -1057,6 +1263,15 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
anchor,
|
anchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Two-thread mode: hand the blocking lock for this bitstream to the retrieve thread.
|
||||||
|
// The sync_channel(POOL) can never fill (in-flight is capped < POOL above).
|
||||||
|
if let Some(rt) = &self.async_rt {
|
||||||
|
if let Some(tx) = &rt.work_tx {
|
||||||
|
let _ = tx.send(RetrieveJob {
|
||||||
|
bs: self.bitstreams[slot] as usize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,6 +1345,26 @@ impl Encoder for NvencCudaEncoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||||
|
// Two-thread mode: drain whatever the retrieve thread has finished (non-blocking) and
|
||||||
|
// hand out the oldest ready AU. `None` = nothing completed yet — the session loop keeps
|
||||||
|
// the frame in flight and re-polls next tick; capture never blocks on the encode wait.
|
||||||
|
if self.async_rt.is_some() {
|
||||||
|
while let Ok(done) = self
|
||||||
|
.async_rt
|
||||||
|
.as_mut()
|
||||||
|
.expect("checked just above")
|
||||||
|
.done_rx
|
||||||
|
.try_recv()
|
||||||
|
{
|
||||||
|
self.absorb_done(done)?;
|
||||||
|
}
|
||||||
|
return Ok(self
|
||||||
|
.async_rt
|
||||||
|
.as_mut()
|
||||||
|
.expect("checked just above")
|
||||||
|
.ready
|
||||||
|
.pop_front());
|
||||||
|
}
|
||||||
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
let Some((bs, map, pts_ns, anchor)) = self.pending.pop_front() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -320,6 +320,17 @@ impl RemoteImporter {
|
|||||||
self.import_impl(plane, ImportKind::Linear, width, height, 0, None)
|
self.import_impl(plane, ImportKind::Linear, width, height, 0, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirror of [`super::egl::EglImporter::import_linear_nv12`] (LINEAR dmabuf → Vulkan-bridge
|
||||||
|
/// compute CSC → two-plane NV12 buffer, latency plan T2.5b).
|
||||||
|
pub fn import_linear_nv12(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<DeviceBuffer> {
|
||||||
|
self.import_impl(plane, ImportKind::LinearNv12, width, height, 0, None)
|
||||||
|
}
|
||||||
|
|
||||||
fn import_impl(
|
fn import_impl(
|
||||||
&mut self,
|
&mut self,
|
||||||
plane: &DmabufPlane,
|
plane: &DmabufPlane,
|
||||||
|
|||||||
@@ -1386,3 +1386,50 @@ pub fn copy_pitched_to_buffer(
|
|||||||
// within both. Wrapper → live table.
|
// within both. Wrapper → live table.
|
||||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(ext->dev)") }
|
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(ext->dev)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// De-stride an NV12 pair from an external mapping (the Vulkan bridge's exportable buffer after
|
||||||
|
/// its compute CSC — latency plan T2.5b) into a pooled two-plane NV12 [`DeviceBuffer`]: the Y
|
||||||
|
/// plane (`width` bytes × `height` rows) and the interleaved UV plane (`width` bytes × ⌈h/2⌉
|
||||||
|
/// rows), each de-strided from `src_pitch` to the pool's own plane pitches. Same contract as
|
||||||
|
/// [`copy_pitched_to_buffer`]: the shared context must be current.
|
||||||
|
pub fn copy_pitched_nv12_to_buffer(
|
||||||
|
y_src: CUdeviceptr,
|
||||||
|
uv_src: CUdeviceptr,
|
||||||
|
src_pitch: usize,
|
||||||
|
dst: &DeviceBuffer,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some((uv_ptr, uv_pitch)) = dst.uv else {
|
||||||
|
anyhow::bail!("copy_pitched_nv12_to_buffer: destination is not an NV12 buffer");
|
||||||
|
};
|
||||||
|
let y = CUDA_MEMCPY2D {
|
||||||
|
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||||
|
srcDevice: y_src,
|
||||||
|
srcPitch: src_pitch,
|
||||||
|
dstMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||||
|
dstDevice: dst.ptr,
|
||||||
|
dstPitch: dst.pitch,
|
||||||
|
WidthInBytes: dst.width as usize,
|
||||||
|
Height: dst.height as usize,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uv = CUDA_MEMCPY2D {
|
||||||
|
srcMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||||
|
srcDevice: uv_src,
|
||||||
|
srcPitch: src_pitch,
|
||||||
|
dstMemoryType: CU_MEMORYTYPE_DEVICE,
|
||||||
|
dstDevice: uv_ptr,
|
||||||
|
dstPitch: uv_pitch,
|
||||||
|
// W/2 interleaved UV samples × 2 bytes = `width` bytes per row.
|
||||||
|
WidthInBytes: dst.width as usize,
|
||||||
|
Height: dst.height.div_ceil(2) as usize,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: same contract as `copy_pitched_to_buffer` — the caller holds the shared context
|
||||||
|
// current; both `CUDA_MEMCPY2D`s are live locals describing spans inside the caller's live
|
||||||
|
// external mapping (`y_src`/`uv_src` at `src_pitch`) and `dst`'s live pooled planes; each
|
||||||
|
// `copy_blocking` synchronizes before returning.
|
||||||
|
unsafe {
|
||||||
|
copy_blocking(&y, "cuMemcpy2DAsync_v2(ext->dev nv12 Y)")?;
|
||||||
|
copy_blocking(&uv, "cuMemcpy2DAsync_v2(ext->dev nv12 UV)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -504,6 +504,9 @@ pub struct EglImporter {
|
|||||||
/// created lazily on the first LINEAR frame, + the destination pool.
|
/// created lazily on the first LINEAR frame, + the destination pool.
|
||||||
vk: Option<super::vulkan::VkBridge>,
|
vk: Option<super::vulkan::VkBridge>,
|
||||||
linear_pool: Option<cuda::BufferPool>,
|
linear_pool: Option<cuda::BufferPool>,
|
||||||
|
/// NV12 twin of [`linear_pool`](Self::linear_pool) for the bridge's compute-CSC output
|
||||||
|
/// (T2.5b) — separate pools because a session may fall back RGB mid-stream.
|
||||||
|
linear_nv12_pool: Option<cuda::BufferPool>,
|
||||||
gbm: *mut c_void,
|
gbm: *mut c_void,
|
||||||
render_fd: c_int,
|
render_fd: c_int,
|
||||||
}
|
}
|
||||||
@@ -647,6 +650,7 @@ impl EglImporter {
|
|||||||
yuv444_blit: None,
|
yuv444_blit: None,
|
||||||
vk: None,
|
vk: None,
|
||||||
linear_pool: None,
|
linear_pool: None,
|
||||||
|
linear_nv12_pool: None,
|
||||||
gbm,
|
gbm,
|
||||||
render_fd,
|
render_fd,
|
||||||
})
|
})
|
||||||
@@ -677,6 +681,38 @@ impl EglImporter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`import_linear`](Self::import_linear), but the bridge's compute CSC converts to a
|
||||||
|
/// two-plane **NV12** buffer (latency plan T2.5b) — the gamescope/LINEAR analogue of
|
||||||
|
/// [`import_nv12`](Self::import_nv12), so NVENC encodes native YUV on the dedicated-session
|
||||||
|
/// path too instead of paying its internal RGB→YUV CSC on the contended SM.
|
||||||
|
pub fn import_linear_nv12(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<DeviceBuffer> {
|
||||||
|
cuda::make_current()?;
|
||||||
|
if self
|
||||||
|
.linear_nv12_pool
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| (p.width(), p.height()))
|
||||||
|
!= Some((width, height))
|
||||||
|
{
|
||||||
|
self.linear_nv12_pool = Some(cuda::BufferPool::new_nv12(width, height)?);
|
||||||
|
}
|
||||||
|
if self.vk.is_none() {
|
||||||
|
self.vk = Some(super::vulkan::VkBridge::new()?);
|
||||||
|
}
|
||||||
|
self.vk.as_mut().unwrap().import_linear_nv12(
|
||||||
|
plane.fd,
|
||||||
|
plane.offset,
|
||||||
|
plane.stride,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
self.linear_nv12_pool.as_ref().unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the Vulkan bridge's cached per-fd import (see [`super::vulkan::VkBridge::forget_fd`]).
|
/// Drop the Vulkan bridge's cached per-fd import (see [`super::vulkan::VkBridge::forget_fd`]).
|
||||||
/// No-op when the bridge hasn't been built (tiled-only captures).
|
/// No-op when the bridge hasn't been built (tiled-only captures).
|
||||||
pub fn forget_linear_fd(&mut self, fd: i32) {
|
pub fn forget_linear_fd(&mut self, fd: i32) {
|
||||||
|
|||||||
@@ -166,6 +166,20 @@ impl Importer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LINEAR dmabuf → Vulkan-bridge compute CSC → two-plane NV12 buffer (latency plan T2.5b —
|
||||||
|
/// the gamescope analogue of [`import_nv12`](Self::import_nv12)).
|
||||||
|
pub fn import_linear_nv12(
|
||||||
|
&mut self,
|
||||||
|
plane: &DmabufPlane,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> anyhow::Result<DeviceBuffer> {
|
||||||
|
match self {
|
||||||
|
Importer::Remote(r) => r.import_linear_nv12(plane, width, height),
|
||||||
|
Importer::InProc(i) => i.import_linear_nv12(plane, width, height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// True once the worker process is gone/wedged (every further call fails fast). Always
|
/// True once the worker process is gone/wedged (every further call fails fast). Always
|
||||||
/// `false` in-process — an in-process driver fault doesn't return.
|
/// `false` in-process — an in-process driver fault doesn't return.
|
||||||
pub fn dead(&self) -> bool {
|
pub fn dead(&self) -> bool {
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ pub enum ImportKind {
|
|||||||
/// variants' wire tags must never shift — an old worker receiving this fails the decode and
|
/// variants' wire tags must never shift — an old worker receiving this fails the decode and
|
||||||
/// the import-fail machinery handles it like any other worker error.
|
/// the import-fail machinery handles it like any other worker error.
|
||||||
Tiled444,
|
Tiled444,
|
||||||
|
/// LINEAR dmabuf → Vulkan-bridge compute CSC → two-plane NV12 CUDA buffer (latency plan
|
||||||
|
/// T2.5b — the gamescope analogue of [`TiledNv12`](Self::TiledNv12)). Appended last, same
|
||||||
|
/// wire-tag rule as [`Tiled444`](Self::Tiled444).
|
||||||
|
LinearNv12,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// host → worker.
|
/// host → worker.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#version 450
|
||||||
|
// LINEAR BGRx dmabuf bytes -> NV12 (BT.709 limited range), BUFFER to BUFFER — the Vulkan-bridge
|
||||||
|
// CSC for the gamescope path (latency plan T2.5b). The bridge deals in VkBuffers (NVIDIA's EGL
|
||||||
|
// can't sample LINEAR and CUDA rejects raw dmabuf fds), so unlike `pf-encode`'s image-based
|
||||||
|
// `rgb2yuv.comp` sibling this reads packed texels straight out of the imported dmabuf buffer and
|
||||||
|
// writes the two NV12 planes into the exportable buffer CUDA reads. Same BT.709 coefficients as
|
||||||
|
// the sibling shader — keep the two in sync.
|
||||||
|
//
|
||||||
|
// One invocation converts a 4x2 luma block: 4 Y bytes = exactly one u32 word per row, and its
|
||||||
|
// two chroma samples = one u32 word — so every store is a whole word and no two invocations
|
||||||
|
// touch the same word (an 8-bit-storage-free way to write byte planes race-free). All pitches
|
||||||
|
// and the UV offset are in WORDS and must be word-aligned (the Rust side sizes them so).
|
||||||
|
//
|
||||||
|
// Rebuild: glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv
|
||||||
|
layout(local_size_x = 8, local_size_y = 8) in;
|
||||||
|
|
||||||
|
layout(std430, binding = 0) readonly buffer Src {
|
||||||
|
uint spx[]; // packed 4-byte texels, byte order B,G,R,X (little-endian XRGB8888/ARGB8888)
|
||||||
|
};
|
||||||
|
layout(std430, binding = 1) writeonly buffer Dst {
|
||||||
|
uint dw[]; // [0, uv_off_w): Y rows at y_pitch_w; [uv_off_w, ...): interleaved UV rows
|
||||||
|
};
|
||||||
|
|
||||||
|
layout(push_constant) uniform Push {
|
||||||
|
uint width; // source pixels
|
||||||
|
uint height;
|
||||||
|
uint src_off_w; // dmabuf plane offset, in words (offset bytes / 4)
|
||||||
|
uint src_pitch_w; // dmabuf row stride, in words
|
||||||
|
uint y_pitch_w; // dst Y row pitch, in words
|
||||||
|
uint uv_off_w; // dst UV plane offset, in words
|
||||||
|
uint uv_pitch_w; // dst UV row pitch, in words
|
||||||
|
} pc;
|
||||||
|
|
||||||
|
// Edge-clamped fetch: alignment padding (x >= width from the 4-wide block, odd-height chroma)
|
||||||
|
// duplicates the last real texel instead of reading out of bounds.
|
||||||
|
vec3 texel(uint x, uint y) {
|
||||||
|
x = min(x, pc.width - 1u);
|
||||||
|
y = min(y, pc.height - 1u);
|
||||||
|
uint t = spx[pc.src_off_w + y * pc.src_pitch_w + x];
|
||||||
|
// B,G,R,X byte order: r = bits 16..23, g = 8..15, b = 0..7.
|
||||||
|
return vec3(float((t >> 16) & 0xFFu), float((t >> 8) & 0xFFu), float(t & 0xFFu)) / 255.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BT.709 limited range — numerically identical to rgb2yuv.comp's lumaY/U/V, scaled to bytes.
|
||||||
|
uint lumaB(vec3 c) {
|
||||||
|
return uint(clamp(16.0 + 255.0 * (0.1826 * c.r + 0.6142 * c.g + 0.0620 * c.b) + 0.5, 0.0, 255.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
uint bx = gl_GlobalInvocationID.x; // 4-px column block
|
||||||
|
uint by = gl_GlobalInvocationID.y; // 2-row block
|
||||||
|
uint x0 = bx * 4u;
|
||||||
|
uint y0 = by * 2u;
|
||||||
|
if (x0 >= pc.width || y0 >= pc.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Two Y words (4 bytes each, rows y0 and y0+1).
|
||||||
|
for (uint row = 0u; row < 2u; row++) {
|
||||||
|
uint y = y0 + row;
|
||||||
|
uint w = lumaB(texel(x0, y))
|
||||||
|
| (lumaB(texel(x0 + 1u, y)) << 8)
|
||||||
|
| (lumaB(texel(x0 + 2u, y)) << 16)
|
||||||
|
| (lumaB(texel(x0 + 3u, y)) << 24);
|
||||||
|
dw[y * pc.y_pitch_w + bx] = w;
|
||||||
|
}
|
||||||
|
// One UV word: the block's two chroma samples (each a 2x2 average).
|
||||||
|
uint uvw = 0u;
|
||||||
|
for (uint s = 0u; s < 2u; s++) {
|
||||||
|
uint cx = x0 + s * 2u;
|
||||||
|
vec3 a = (texel(cx, y0) + texel(cx + 1u, y0) + texel(cx, y0 + 1u) + texel(cx + 1u, y0 + 1u))
|
||||||
|
* 0.25;
|
||||||
|
uint U = uint(clamp(128.0 + 255.0 * (-0.1006 * a.r - 0.3386 * a.g + 0.4392 * a.b) + 0.5, 0.0, 255.0));
|
||||||
|
uint V = uint(clamp(128.0 + 255.0 * (0.4392 * a.r - 0.3989 * a.g - 0.0403 * a.b) + 0.5, 0.0, 255.0));
|
||||||
|
uvw |= (U | (V << 8)) << (16u * s);
|
||||||
|
}
|
||||||
|
dw[pc.uv_off_w + by * pc.uv_pitch_w + bx] = uvw;
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -40,6 +40,20 @@ struct DstBuf {
|
|||||||
cuda: cuda::ExternalDmabuf,
|
cuda: cuda::ExternalDmabuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The lazy compute-CSC pipeline (`rgb2nv12_buf.comp`) for [`VkBridge::import_linear_nv12`].
|
||||||
|
struct Csc {
|
||||||
|
module: vk::ShaderModule,
|
||||||
|
dset_layout: vk::DescriptorSetLayout,
|
||||||
|
playout: vk::PipelineLayout,
|
||||||
|
pipeline: vk::Pipeline,
|
||||||
|
dpool: vk::DescriptorPool,
|
||||||
|
dset: vk::DescriptorSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The buffer-to-buffer RGB→NV12 compute shader (see `rgb2nv12_buf.comp` beside this file;
|
||||||
|
/// rebuild with `glslc rgb2nv12_buf.comp -o rgb2nv12_buf.spv`).
|
||||||
|
const CSC_SPV: &[u8] = include_bytes!("rgb2nv12_buf.spv");
|
||||||
|
|
||||||
pub struct VkBridge {
|
pub struct VkBridge {
|
||||||
_entry: ash::Entry,
|
_entry: ash::Entry,
|
||||||
instance: ash::Instance,
|
instance: ash::Instance,
|
||||||
@@ -52,6 +66,9 @@ pub struct VkBridge {
|
|||||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||||
src_cache: HashMap<i32, SrcBuf>,
|
src_cache: HashMap<i32, SrcBuf>,
|
||||||
dst: Option<DstBuf>,
|
dst: Option<DstBuf>,
|
||||||
|
/// Built on the first [`import_linear_nv12`](Self::import_linear_nv12); RGB-only bridges
|
||||||
|
/// never pay for it.
|
||||||
|
csc: Option<Csc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
|
// SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
|
||||||
@@ -94,18 +111,15 @@ impl VkBridge {
|
|||||||
.ok_or_else(|| anyhow!("no NVIDIA Vulkan device"))?;
|
.ok_or_else(|| anyhow!("no NVIDIA Vulkan device"))?;
|
||||||
let mem_props = instance.get_physical_device_memory_properties(phys);
|
let mem_props = instance.get_physical_device_memory_properties(phys);
|
||||||
|
|
||||||
// Any queue family supporting transfer (graphics/compute imply it).
|
// A COMPUTE-capable family (compute implies transfer): the copy path only needs
|
||||||
|
// transfer, but the NV12 CSC dispatch (T2.5b) needs compute — on every NVIDIA
|
||||||
|
// device family 0 is graphics+compute+transfer, so this picks the same family the
|
||||||
|
// old transfer-only predicate did.
|
||||||
let qf = instance
|
let qf = instance
|
||||||
.get_physical_device_queue_family_properties(phys)
|
.get_physical_device_queue_family_properties(phys)
|
||||||
.iter()
|
.iter()
|
||||||
.position(|q| {
|
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
|
||||||
q.queue_flags.intersects(
|
.ok_or_else(|| anyhow!("no compute-capable queue family"))?
|
||||||
vk::QueueFlags::TRANSFER
|
|
||||||
| vk::QueueFlags::GRAPHICS
|
|
||||||
| vk::QueueFlags::COMPUTE,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.ok_or_else(|| anyhow!("no transfer-capable queue family"))?
|
|
||||||
as u32;
|
as u32;
|
||||||
|
|
||||||
let exts = [
|
let exts = [
|
||||||
@@ -161,6 +175,7 @@ impl VkBridge {
|
|||||||
mem_props,
|
mem_props,
|
||||||
src_cache: HashMap::new(),
|
src_cache: HashMap::new(),
|
||||||
dst: None,
|
dst: None,
|
||||||
|
csc: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,7 +204,11 @@ impl VkBridge {
|
|||||||
.create_buffer(
|
.create_buffer(
|
||||||
&vk::BufferCreateInfo::default()
|
&vk::BufferCreateInfo::default()
|
||||||
.size(size)
|
.size(size)
|
||||||
.usage(vk::BufferUsageFlags::TRANSFER_SRC)
|
// STORAGE so the NV12 compute CSC can read it as an SSBO (T2.5b); harmless
|
||||||
|
// for the plain copy path.
|
||||||
|
.usage(
|
||||||
|
vk::BufferUsageFlags::TRANSFER_SRC | vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||||
|
)
|
||||||
.push_next(&mut ext_info),
|
.push_next(&mut ext_info),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -256,7 +275,10 @@ impl VkBridge {
|
|||||||
.create_buffer(
|
.create_buffer(
|
||||||
&vk::BufferCreateInfo::default()
|
&vk::BufferCreateInfo::default()
|
||||||
.size(size)
|
.size(size)
|
||||||
.usage(vk::BufferUsageFlags::TRANSFER_DST)
|
// STORAGE so the NV12 compute CSC can write it as an SSBO (T2.5b).
|
||||||
|
.usage(
|
||||||
|
vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||||
|
)
|
||||||
.push_next(&mut ext_info),
|
.push_next(&mut ext_info),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -302,6 +324,246 @@ impl VkBridge {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the RGB→NV12 compute pipeline once (T2.5b): two-SSBO descriptor set + a 28-byte
|
||||||
|
/// push-constant block matching `rgb2nv12_buf.comp`'s `Push`.
|
||||||
|
unsafe fn ensure_csc(&mut self) -> Result<()> {
|
||||||
|
if self.csc.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let words: Vec<u32> = CSC_SPV
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
|
||||||
|
.collect();
|
||||||
|
let module = self
|
||||||
|
.device
|
||||||
|
.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&words), None)
|
||||||
|
.context("create CSC shader module")?;
|
||||||
|
let bindings = [
|
||||||
|
vk::DescriptorSetLayoutBinding::default()
|
||||||
|
.binding(0)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
|
.descriptor_count(1)
|
||||||
|
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||||
|
vk::DescriptorSetLayoutBinding::default()
|
||||||
|
.binding(1)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
|
.descriptor_count(1)
|
||||||
|
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||||
|
];
|
||||||
|
let dset_layout = self
|
||||||
|
.device
|
||||||
|
.create_descriptor_set_layout(
|
||||||
|
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("create CSC dset layout")?;
|
||||||
|
let pc = [vk::PushConstantRange::default()
|
||||||
|
.stage_flags(vk::ShaderStageFlags::COMPUTE)
|
||||||
|
.size(28)];
|
||||||
|
let layouts = [dset_layout];
|
||||||
|
let playout = self
|
||||||
|
.device
|
||||||
|
.create_pipeline_layout(
|
||||||
|
&vk::PipelineLayoutCreateInfo::default()
|
||||||
|
.set_layouts(&layouts)
|
||||||
|
.push_constant_ranges(&pc),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("create CSC pipeline layout")?;
|
||||||
|
let entry = c"main";
|
||||||
|
let stage = vk::PipelineShaderStageCreateInfo::default()
|
||||||
|
.stage(vk::ShaderStageFlags::COMPUTE)
|
||||||
|
.module(module)
|
||||||
|
.name(entry);
|
||||||
|
let pipeline = self
|
||||||
|
.device
|
||||||
|
.create_compute_pipelines(
|
||||||
|
vk::PipelineCache::null(),
|
||||||
|
&[vk::ComputePipelineCreateInfo::default()
|
||||||
|
.stage(stage)
|
||||||
|
.layout(playout)],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|(_, e)| anyhow!("create CSC pipeline: {e}"))?[0];
|
||||||
|
let sizes = [vk::DescriptorPoolSize::default()
|
||||||
|
.ty(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
|
.descriptor_count(2)];
|
||||||
|
let dpool = self
|
||||||
|
.device
|
||||||
|
.create_descriptor_pool(
|
||||||
|
&vk::DescriptorPoolCreateInfo::default()
|
||||||
|
.max_sets(1)
|
||||||
|
.pool_sizes(&sizes),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.context("create CSC descriptor pool")?;
|
||||||
|
let dset = self
|
||||||
|
.device
|
||||||
|
.allocate_descriptor_sets(
|
||||||
|
&vk::DescriptorSetAllocateInfo::default()
|
||||||
|
.descriptor_pool(dpool)
|
||||||
|
.set_layouts(&layouts),
|
||||||
|
)
|
||||||
|
.context("allocate CSC descriptor set")?[0];
|
||||||
|
self.csc = Some(Csc {
|
||||||
|
module,
|
||||||
|
dset_layout,
|
||||||
|
playout,
|
||||||
|
pipeline,
|
||||||
|
dpool,
|
||||||
|
dset,
|
||||||
|
});
|
||||||
|
tracing::info!("Vulkan-bridge NV12 compute CSC ready (LINEAR path feeds NVENC native YUV)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge one LINEAR dmabuf frame into a pooled NV12 CUDA buffer (latency plan T2.5b):
|
||||||
|
/// instead of the plain byte copy, the compute CSC reads the imported RGB texels and writes
|
||||||
|
/// both NV12 planes into the exportable buffer, so NVENC on the gamescope path encodes
|
||||||
|
/// native YUV (its internal RGB→YUV CSC on the contended SM disappears). `pool` must be an
|
||||||
|
/// NV12 pool ([`cuda::BufferPool::new_nv12`]).
|
||||||
|
pub fn import_linear_nv12(
|
||||||
|
&mut self,
|
||||||
|
fd: i32,
|
||||||
|
offset: u32,
|
||||||
|
stride: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
pool: &cuda::BufferPool,
|
||||||
|
) -> Result<DeviceBuffer> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
offset % 4 == 0 && stride % 4 == 0,
|
||||||
|
"LINEAR dmabuf offset/stride not word-aligned ({offset}/{stride})"
|
||||||
|
);
|
||||||
|
// Exportable-buffer NV12 layout the shader writes: 4-aligned Y pitch, UV plane (⌈h/2⌉
|
||||||
|
// rows at the same pitch) directly after the Y plane.
|
||||||
|
let y_pitch = (width as u64 + 3) & !3;
|
||||||
|
let uv_off = y_pitch * height as u64;
|
||||||
|
let dst_size = uv_off + y_pitch * height.div_ceil(2) as u64;
|
||||||
|
// SAFETY: same structure and proofs as `import_linear` — `fd` is the caller's live dmabuf
|
||||||
|
// (dup'd by `import_src`), sizes are checked (`import_src` asserts the fd covers
|
||||||
|
// `offset + stride*height`; `ensure_dst(dst_size)` makes the exportable buffer at least
|
||||||
|
// the shader's whole write range, whose last word is `dst_size - 4`). The descriptor
|
||||||
|
// update binds the live cached src buffer and the live dst buffer WHOLE_SIZE; every
|
||||||
|
// `*Info`/array is a local outliving its synchronous call; `cmd`/`queue`/`fence` are this
|
||||||
|
// bridge's own single-thread handles. The dispatch covers ⌈w/32⌉×⌈h/16⌉ groups of 8×8
|
||||||
|
// invocations, each writing only whole words inside the proven dst range (shader
|
||||||
|
// contract). The host `wait_for_fences` retires the compute pass (with a shader-write →
|
||||||
|
// memory barrier recorded before end) BEFORE CUDA reads the shared memory.
|
||||||
|
unsafe {
|
||||||
|
let span = offset as u64 + stride as u64 * height as u64;
|
||||||
|
if !self.src_cache.contains_key(&fd) {
|
||||||
|
let size = libc::lseek(fd, 0, libc::SEEK_END);
|
||||||
|
anyhow::ensure!(size > 0, "lseek(dmabuf)");
|
||||||
|
anyhow::ensure!(size as u64 >= span, "dmabuf smaller than frame span");
|
||||||
|
self.import_src(fd, size as u64)?;
|
||||||
|
}
|
||||||
|
let src_buffer = self.src_cache[&fd].buffer;
|
||||||
|
self.ensure_dst(dst_size)?;
|
||||||
|
self.ensure_csc()?;
|
||||||
|
let (dst_buffer, dst_cuda_ptr) = {
|
||||||
|
let d = self.dst.as_ref().unwrap();
|
||||||
|
(d.buffer, d.cuda.ptr)
|
||||||
|
};
|
||||||
|
let csc = self.csc.as_ref().unwrap();
|
||||||
|
|
||||||
|
let src_info = [vk::DescriptorBufferInfo::default()
|
||||||
|
.buffer(src_buffer)
|
||||||
|
.range(vk::WHOLE_SIZE)];
|
||||||
|
let dst_info = [vk::DescriptorBufferInfo::default()
|
||||||
|
.buffer(dst_buffer)
|
||||||
|
.range(vk::WHOLE_SIZE)];
|
||||||
|
let writes = [
|
||||||
|
vk::WriteDescriptorSet::default()
|
||||||
|
.dst_set(csc.dset)
|
||||||
|
.dst_binding(0)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
|
.buffer_info(&src_info),
|
||||||
|
vk::WriteDescriptorSet::default()
|
||||||
|
.dst_set(csc.dset)
|
||||||
|
.dst_binding(1)
|
||||||
|
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||||
|
.buffer_info(&dst_info),
|
||||||
|
];
|
||||||
|
self.device.update_descriptor_sets(&writes, &[]);
|
||||||
|
|
||||||
|
self.device
|
||||||
|
.begin_command_buffer(
|
||||||
|
self.cmd,
|
||||||
|
&vk::CommandBufferBeginInfo::default()
|
||||||
|
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||||
|
)
|
||||||
|
.context("begin cmd")?;
|
||||||
|
self.device
|
||||||
|
.cmd_bind_pipeline(self.cmd, vk::PipelineBindPoint::COMPUTE, csc.pipeline);
|
||||||
|
self.device.cmd_bind_descriptor_sets(
|
||||||
|
self.cmd,
|
||||||
|
vk::PipelineBindPoint::COMPUTE,
|
||||||
|
csc.playout,
|
||||||
|
0,
|
||||||
|
&[csc.dset],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
let push: [u32; 7] = [
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
offset / 4,
|
||||||
|
stride / 4,
|
||||||
|
(y_pitch / 4) as u32,
|
||||||
|
(uv_off / 4) as u32,
|
||||||
|
(y_pitch / 4) as u32,
|
||||||
|
];
|
||||||
|
let push_bytes: &[u8] = std::slice::from_raw_parts(push.as_ptr().cast(), 28);
|
||||||
|
self.device.cmd_push_constants(
|
||||||
|
self.cmd,
|
||||||
|
csc.playout,
|
||||||
|
vk::ShaderStageFlags::COMPUTE,
|
||||||
|
0,
|
||||||
|
push_bytes,
|
||||||
|
);
|
||||||
|
self.device
|
||||||
|
.cmd_dispatch(self.cmd, width.div_ceil(32), height.div_ceil(16), 1);
|
||||||
|
// Make the shader writes available before the external (CUDA) read.
|
||||||
|
let barrier = vk::MemoryBarrier::default()
|
||||||
|
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||||
|
.dst_access_mask(vk::AccessFlags::MEMORY_READ);
|
||||||
|
self.device.cmd_pipeline_barrier(
|
||||||
|
self.cmd,
|
||||||
|
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||||
|
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||||
|
vk::DependencyFlags::empty(),
|
||||||
|
&[barrier],
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
self.device
|
||||||
|
.end_command_buffer(self.cmd)
|
||||||
|
.context("end cmd")?;
|
||||||
|
let cmds = [self.cmd];
|
||||||
|
let submit = vk::SubmitInfo::default().command_buffers(&cmds);
|
||||||
|
self.device
|
||||||
|
.queue_submit(self.queue, &[submit], self.fence)
|
||||||
|
.context("queue submit")?;
|
||||||
|
self.device
|
||||||
|
.wait_for_fences(&[self.fence], true, 1_000_000_000)
|
||||||
|
.context("fence wait")?;
|
||||||
|
self.device
|
||||||
|
.reset_fences(&[self.fence])
|
||||||
|
.context("reset fence")?;
|
||||||
|
|
||||||
|
// De-stride both NV12 planes from the CUDA view into a pooled two-plane buffer.
|
||||||
|
cuda::make_current()?;
|
||||||
|
let out = pool.get()?;
|
||||||
|
cuda::copy_pitched_nv12_to_buffer(
|
||||||
|
dst_cuda_ptr,
|
||||||
|
dst_cuda_ptr + uv_off,
|
||||||
|
y_pitch as usize,
|
||||||
|
&out,
|
||||||
|
)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the cached import for `fd` (the PipeWire buffer it wrapped is gone — pool recycle /
|
/// Drop the cached import for `fd` (the PipeWire buffer it wrapped is gone — pool recycle /
|
||||||
/// renegotiation — or the caller is about to store a different dmabuf under the same slot).
|
/// renegotiation — or the caller is about to store a different dmabuf under the same slot).
|
||||||
/// Without this the cache could serve a stale imported buffer for a reused fd number, or
|
/// Without this the cache could serve a stale imported buffer for a reused fd number, or
|
||||||
@@ -414,6 +676,14 @@ impl Drop for VkBridge {
|
|||||||
self.device.destroy_buffer(d.buffer, None);
|
self.device.destroy_buffer(d.buffer, None);
|
||||||
self.device.free_memory(d.memory, None);
|
self.device.free_memory(d.memory, None);
|
||||||
}
|
}
|
||||||
|
if let Some(c) = self.csc.take() {
|
||||||
|
self.device.destroy_pipeline(c.pipeline, None);
|
||||||
|
self.device.destroy_pipeline_layout(c.playout, None);
|
||||||
|
self.device.destroy_descriptor_pool(c.dpool, None); // frees `c.dset` with it
|
||||||
|
self.device
|
||||||
|
.destroy_descriptor_set_layout(c.dset_layout, None);
|
||||||
|
self.device.destroy_shader_module(c.module, None);
|
||||||
|
}
|
||||||
self.device.destroy_fence(self.fence, None);
|
self.device.destroy_fence(self.fence, None);
|
||||||
self.device.destroy_command_pool(self.cmd_pool, None);
|
self.device.destroy_command_pool(self.cmd_pool, None);
|
||||||
self.device.destroy_device(None);
|
self.device.destroy_device(None);
|
||||||
|
|||||||
@@ -299,6 +299,9 @@ impl EglBackend {
|
|||||||
req.modifier,
|
req.modifier,
|
||||||
)?,
|
)?,
|
||||||
ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?,
|
ImportKind::Linear => self.importer.import_linear(&plane, req.width, req.height)?,
|
||||||
|
ImportKind::LinearNv12 => self
|
||||||
|
.importer
|
||||||
|
.import_linear_nv12(&plane, req.width, req.height)?,
|
||||||
};
|
};
|
||||||
// Assign / look up the buffer's id and export its CUDA IPC identity on first delivery.
|
// Assign / look up the buffer's id and export its CUDA IPC identity on first delivery.
|
||||||
cuda::make_current()?;
|
cuda::make_current()?;
|
||||||
|
|||||||
@@ -45,12 +45,20 @@ const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
|
|||||||
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
|
/// died at full rate over WiFi). Same lossy-drop contract as `WouldBlock`; FEC + the next frame
|
||||||
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
|
/// recover. Asynchronous network-path blips (`ENETUNREACH`/`EHOSTUNREACH`/`ENETDOWN`/`EHOSTDOWN`)
|
||||||
/// are droppable for the same reason a stale ICMP is.
|
/// are droppable for the same reason a stale ICMP is.
|
||||||
|
/// - Windows `WSAENOBUFS` (10055): the exact analogue of unix `ENOBUFS` — a high-bitrate keyframe
|
||||||
|
/// burst (one `WSASendMsg` USO super-buffer is up to ~512 segments ≈ 700 KB) momentarily exhausts
|
||||||
|
/// the socket send buffer / AFD non-paged pool, and Winsock reports `WSAENOBUFS`, which Rust maps
|
||||||
|
/// to `ErrorKind::Uncategorized` (so the `WouldBlock` arm misses it, exactly like unix `ENOBUFS`).
|
||||||
|
/// Without treating it as transient a Windows host tears the whole session down under load
|
||||||
|
/// (observed live: `native::stream` "send failed — stopping stream" on a paced video burst). Same
|
||||||
|
/// lossy-drop contract; FEC + the next frame recover. The `WSAENET*`/`WSAEHOST*` family is the
|
||||||
|
/// Windows counterpart of the droppable unix network-path blips above.
|
||||||
fn is_transient_io(e: &std::io::Error) -> bool {
|
fn is_transient_io(e: &std::io::Error) -> bool {
|
||||||
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
use std::io::ErrorKind::{ConnectionRefused, ConnectionReset, WouldBlock};
|
||||||
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
if matches!(e.kind(), WouldBlock | ConnectionRefused | ConnectionReset) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno (unix only).
|
// `ENOBUFS` & friends have no stable `ErrorKind`, so match the raw errno.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
matches!(
|
matches!(
|
||||||
@@ -62,7 +70,20 @@ fn is_transient_io(e: &std::io::Error) -> bool {
|
|||||||
| Some(libc::EHOSTDOWN)
|
| Some(libc::EHOSTDOWN)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#[cfg(not(unix))]
|
// Windows Winsock codes (WSAE*), raw like the sibling `uso_unsupported`. WSAEWOULDBLOCK (10035)
|
||||||
|
// already maps to `ErrorKind::WouldBlock` above, so it isn't repeated here.
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
matches!(
|
||||||
|
e.raw_os_error(),
|
||||||
|
Some(10055) // WSAENOBUFS — tx queue / send buffer full (the dominant high-bitrate drop)
|
||||||
|
| Some(10051) // WSAENETUNREACH
|
||||||
|
| Some(10065) // WSAEHOSTUNREACH
|
||||||
|
| Some(10050) // WSAENETDOWN
|
||||||
|
| Some(10064) // WSAEHOSTDOWN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
{
|
{
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -324,6 +345,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The raw-errno tx-queue-full / network-blip codes have no stable `ErrorKind` (they surface as
|
||||||
|
/// `Uncategorized`), so they only get caught by the platform `raw_os_error()` arms. A burst that
|
||||||
|
/// momentarily exhausts the send buffer must stay a lossy drop, never a teardown — this is the
|
||||||
|
/// regression guard for the Windows `WSAENOBUFS` (10055) session crash and the unix `ENOBUFS`
|
||||||
|
/// wlan-driver case. Gated per platform because a code is only classified on its own OS.
|
||||||
|
#[test]
|
||||||
|
fn transient_io_covers_raw_tx_queue_and_path_codes() {
|
||||||
|
use std::io::Error;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
for code in [
|
||||||
|
libc::ENOBUFS,
|
||||||
|
libc::ENETUNREACH,
|
||||||
|
libc::EHOSTUNREACH,
|
||||||
|
libc::ENETDOWN,
|
||||||
|
libc::EHOSTDOWN,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
is_transient_io(&Error::from_raw_os_error(code)),
|
||||||
|
"unix errno {code} should be transient"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A genuine failure with no stable ErrorKind must still tear down.
|
||||||
|
assert!(
|
||||||
|
!is_transient_io(&Error::from_raw_os_error(libc::EACCES)),
|
||||||
|
"EACCES must stay fatal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// WSAENOBUFS / WSAENETUNREACH / WSAEHOSTUNREACH / WSAENETDOWN / WSAEHOSTDOWN.
|
||||||
|
for code in [10055, 10051, 10065, 10050, 10064] {
|
||||||
|
assert!(
|
||||||
|
is_transient_io(&Error::from_raw_os_error(code)),
|
||||||
|
"WSA code {code} should be transient"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// WSAEACCES (10013) — a real failure that must stay fatal.
|
||||||
|
assert!(
|
||||||
|
!is_transient_io(&Error::from_raw_os_error(10013)),
|
||||||
|
"WSAEACCES must stay fatal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
|
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
|
||||||
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
|
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
|
||||||
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
|
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
# back to the real module path, so the console's target column and the ring's noise gate see
|
# back to the real module path, so the console's target column and the ring's noise gate see
|
||||||
# `mdns_sd::…` instead of "log".
|
# `mdns_sd::…` instead of "log".
|
||||||
tracing-log = "0.2"
|
tracing-log = "0.2"
|
||||||
|
# `log::LevelFilter` for the bridge's max-level in `log_capture::install_global` (which inits the
|
||||||
|
# tracing-log bridge with `ignore_crate("wasapi")`). Already in the tree transitively.
|
||||||
|
log = "0.4"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
mdns-sd = "0.20"
|
mdns-sd = "0.20"
|
||||||
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
|
# Wake-on-LAN: report the host's wake-capable NIC MAC(s) to clients via the mDNS `mac` TXT record.
|
||||||
@@ -77,17 +80,20 @@ base64 = "0.22"
|
|||||||
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
||||||
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
||||||
ureq = "2"
|
ureq = "2"
|
||||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
rcgen = { version = "0.13", default-features = false, features = ["ring", "pem"] }
|
||||||
x509-parser = "0.16"
|
x509-parser = "0.16"
|
||||||
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
|
# Only used for the plain-HTTP nvhttp listener (`bind().serve()`); HTTPS/mTLS is hand-rolled over
|
||||||
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
|
# tokio-rustls (axum-server can't surface the peer cert), so we do NOT enable `tls-rustls` — that
|
||||||
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
|
# feature is what pulled the unmaintained `rustls-pemfile` (security-review dep hygiene).
|
||||||
axum-server = "0.8"
|
axum-server = "0.8"
|
||||||
rustls = "0.23"
|
# Ring backend, NOT the (default) aws-lc-rs one — matches punktfunk-core + the client so the whole
|
||||||
|
# tree stays ring-only (no aws-lc-sys: a heavy C dep that fails to build on the Windows CI runner).
|
||||||
|
# Keep `tls12` for GameStream/Moonlight clients that negotiate TLS 1.2.
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||||
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
|
# Manual HTTPS+mTLS serve loop for the mgmt API (axum-server can't surface the peer cert): a
|
||||||
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
|
# tokio-rustls handshake exposes the client cert, then hyper serves the axum Router with the
|
||||||
# verified fingerprint injected as a request extension. Versions match the workspace lock.
|
# verified fingerprint injected as a request extension. Versions match the workspace lock.
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||||
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||||
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
|
hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
@@ -114,8 +120,6 @@ tower = { version = "0.5", features = ["util"] }
|
|||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
|
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
# Emit `log`-crate records through the tracing-log bridge in the log_capture tests.
|
|
||||||
log = "0.4"
|
|
||||||
|
|
||||||
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
|
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
|
||||||
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
|
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
|
||||||
|
|||||||
@@ -164,6 +164,12 @@ pub enum EventKind {
|
|||||||
/// API (RFC §8) lands.
|
/// API (RFC §8) lands.
|
||||||
source: String,
|
source: String,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "plugins.changed")]
|
||||||
|
PluginsChanged {
|
||||||
|
/// The plugin whose registration changed (registered, restarted, deregistered, or
|
||||||
|
/// lease-expired). A consumer re-reads `GET /api/v1/plugins` for the new set.
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
#[serde(rename = "host.started")]
|
#[serde(rename = "host.started")]
|
||||||
HostStarted {
|
HostStarted {
|
||||||
version: String,
|
version: String,
|
||||||
@@ -190,6 +196,7 @@ impl EventKind {
|
|||||||
EventKind::DisplayCreated { .. } => "display.created",
|
EventKind::DisplayCreated { .. } => "display.created",
|
||||||
EventKind::DisplayReleased { .. } => "display.released",
|
EventKind::DisplayReleased { .. } => "display.released",
|
||||||
EventKind::LibraryChanged { .. } => "library.changed",
|
EventKind::LibraryChanged { .. } => "library.changed",
|
||||||
|
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||||
EventKind::HostStarted { .. } => "host.started",
|
EventKind::HostStarted { .. } => "host.started",
|
||||||
EventKind::HostStopping => "host.stopping",
|
EventKind::HostStopping => "host.stopping",
|
||||||
}
|
}
|
||||||
@@ -495,6 +502,19 @@ mod tests {
|
|||||||
serde_json::to_string(&ev).unwrap(),
|
serde_json::to_string(&ev).unwrap(),
|
||||||
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
|
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let ev = HostEvent {
|
||||||
|
seq: 3,
|
||||||
|
ts_ms: 1_700_000_000_000,
|
||||||
|
schema: 1,
|
||||||
|
kind: EventKind::PluginsChanged {
|
||||||
|
id: "rom-manager".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ev).unwrap(),
|
||||||
|
r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"#
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use pf_paths::config_dir;
|
use pf_paths::config_dir;
|
||||||
use rsa::pkcs1v15::SigningKey;
|
use rsa::pkcs1v15::SigningKey;
|
||||||
use rsa::pkcs8::DecodePrivateKey;
|
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||||
use rsa::RsaPrivateKey;
|
use rsa::RsaPrivateKey;
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -70,7 +70,20 @@ impl ServerIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn generate() -> Result<(String, String)> {
|
fn generate() -> Result<(String, String)> {
|
||||||
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?;
|
// The workspace is ring-only (aws-lc-sys breaks Windows CI — see the rustls/rcgen pins), and
|
||||||
|
// `ring` can *sign* with an existing RSA key but cannot *generate* one: rcgen's ring backend
|
||||||
|
// returns `KeyGenerationUnavailable` for `generate_for(&PKCS_RSA_SHA256)`. Moonlight requires an
|
||||||
|
// RSA-2048 identity, so generate the key with the pure-Rust `rsa` crate (already a dep for the
|
||||||
|
// pairing signer) and hand the PKCS#8 PEM to rcgen, whose ring backend *can* load + self-sign
|
||||||
|
// with it. Returning that same PEM keeps it byte-identical to what `from_pems` re-parses.
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let priv_key = RsaPrivateKey::new(&mut rng, 2048).context("generate RSA-2048 host key")?;
|
||||||
|
let key_pem = priv_key
|
||||||
|
.to_pkcs8_pem(LineEnding::LF)
|
||||||
|
.context("encode host key as PKCS#8 PEM")?
|
||||||
|
.to_string();
|
||||||
|
let key = rcgen::KeyPair::from_pkcs8_pem_and_sign_algo(&key_pem, &rcgen::PKCS_RSA_SHA256)
|
||||||
|
.context("load RSA host key into rcgen")?;
|
||||||
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
|
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
|
||||||
params
|
params
|
||||||
.distinguished_name
|
.distinguished_name
|
||||||
@@ -78,7 +91,7 @@ fn generate() -> Result<(String, String)> {
|
|||||||
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
|
||||||
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
|
||||||
let cert = params.self_signed(&key).context("self-sign cert")?;
|
let cert = params.self_signed(&key).context("self-sign cert")?;
|
||||||
Ok((cert.pem(), key.serialize_pem()))
|
Ok((cert.pem(), key_pem))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the X.509 `signatureValue` bytes from a cert PEM.
|
/// Extract the X.509 `signatureValue` bytes from a cert PEM.
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ pub fn serve(
|
|||||||
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
|
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
// rustls needs a process-wide crypto provider before any TLS config is built.
|
// rustls needs a process-wide crypto provider before any TLS config is built.
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let native_opts = crate::native::native_serve_opts(&native);
|
let native_opts = crate::native::native_serve_opts(&native);
|
||||||
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
|
// The hook runner consumes the live event tail for the host's lifetime — spawned BEFORE
|
||||||
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
|
// `host.started` is emitted so operator hooks observe the full lifecycle (RFC §6).
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ fn build_server_config(
|
|||||||
key_pem: &str,
|
key_pem: &str,
|
||||||
mandatory: bool,
|
mandatory: bool,
|
||||||
) -> Result<Arc<ServerConfig>> {
|
) -> Result<Arc<ServerConfig>> {
|
||||||
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||||
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
|
// PEM parsing via rustls-pki-types (the same `PemObject` path punktfunk-core/quic.rs uses),
|
||||||
// so we don't pull the unmaintained `rustls-pemfile`.
|
// so we don't pull the unmaintained `rustls-pemfile`.
|
||||||
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
|
let certs = CertificateDer::pem_slice_iter(cert_pem.as_bytes())
|
||||||
|
|||||||
@@ -176,16 +176,48 @@ impl HooksConfig {
|
|||||||
|
|
||||||
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
|
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
|
||||||
/// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds.
|
/// private dir, temp-write + atomic rename, in-memory value changes only if the write succeeds.
|
||||||
|
///
|
||||||
|
/// A hand-edited `hooks.json` is honored WITHOUT a restart (the documented contract): [`get`]
|
||||||
|
/// re-stats the file and reloads when its identity (mtime + length) moved. The stat rides the
|
||||||
|
/// per-event dispatch, so the check costs one `metadata()` call per event, and a full re-read
|
||||||
|
/// happens only when the file actually changed.
|
||||||
|
///
|
||||||
|
/// [`get`]: HooksStore::get
|
||||||
pub struct HooksStore {
|
pub struct HooksStore {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
cur: Mutex<Option<HooksConfig>>,
|
cur: Mutex<StoreState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreState {
|
||||||
|
cfg: Option<HooksConfig>,
|
||||||
|
/// Identity of the file revision `cfg` was parsed from (mtime + length); `None` = the file
|
||||||
|
/// did not exist. `get` compares against a fresh stat to detect hand edits.
|
||||||
|
file_id: Option<(std::time::SystemTime, u64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HooksStore {
|
impl HooksStore {
|
||||||
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
|
/// Load from `path`. Missing file ⇒ no hooks; corrupt file ⇒ no hooks with a warning
|
||||||
/// (never fail host startup over a settings file).
|
/// (never fail host startup over a settings file).
|
||||||
pub fn load_from(path: PathBuf) -> Self {
|
pub fn load_from(path: PathBuf) -> Self {
|
||||||
let cur = match std::fs::read(&path) {
|
let (cfg, file_id) = Self::read_disk(&path);
|
||||||
|
HooksStore {
|
||||||
|
path,
|
||||||
|
cur: Mutex::new(StoreState { cfg, file_id }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's on-disk identity, `None` when it does not exist (or cannot be stat'd —
|
||||||
|
/// indistinguishable on purpose: both mean "no usable hooks file").
|
||||||
|
fn file_identity(path: &PathBuf) -> Option<(std::time::SystemTime, u64)> {
|
||||||
|
let meta = std::fs::metadata(path).ok()?;
|
||||||
|
Some((meta.modified().ok()?, meta.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read + validate the file. Same lenient contract as startup: missing ⇒ no hooks;
|
||||||
|
/// invalid/unreadable ⇒ no hooks with a warning naming the problem.
|
||||||
|
fn read_disk(path: &PathBuf) -> (Option<HooksConfig>, Option<(std::time::SystemTime, u64)>) {
|
||||||
|
let file_id = Self::file_identity(path);
|
||||||
|
let cfg = match std::fs::read(path) {
|
||||||
Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) {
|
Ok(bytes) => match serde_json::from_slice::<HooksConfig>(&bytes) {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
if let Err(e) = c.validate() {
|
if let Err(e) = c.validate() {
|
||||||
@@ -204,15 +236,23 @@ impl HooksStore {
|
|||||||
},
|
},
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
HooksStore {
|
(cfg, file_id)
|
||||||
path,
|
|
||||||
cur: Mutex::new(cur),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
|
/// The stored configuration (empty when unconfigured) — the mgmt GET and the dispatcher.
|
||||||
|
/// Re-reads `hooks.json` first if it changed on disk since last load, so hand edits apply
|
||||||
|
/// on the next event, no restart ("changes apply immediately" — docs/automation.md).
|
||||||
pub fn get(&self) -> HooksConfig {
|
pub fn get(&self) -> HooksConfig {
|
||||||
self.cur.lock().unwrap().clone().unwrap_or_default()
|
let mut st = self.cur.lock().unwrap();
|
||||||
|
let now_id = Self::file_identity(&self.path);
|
||||||
|
if now_id != st.file_id {
|
||||||
|
let (cfg, file_id) = Self::read_disk(&self.path);
|
||||||
|
tracing::info!(path = %self.path.display(), hooks = cfg.as_ref().map_or(0, |c| c.hooks.len()),
|
||||||
|
"hooks.json changed on disk — reloaded");
|
||||||
|
st.cfg = cfg;
|
||||||
|
st.file_id = file_id;
|
||||||
|
}
|
||||||
|
st.cfg.clone().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist + adopt a new configuration (caller validates first). The in-memory value
|
/// Persist + adopt a new configuration (caller validates first). The in-memory value
|
||||||
@@ -224,12 +264,15 @@ impl HooksStore {
|
|||||||
let tmp = self.path.with_extension("json.tmp");
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
|
pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&cfg)?)?;
|
||||||
std::fs::rename(&tmp, &self.path)?;
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
*self.cur.lock().unwrap() = Some(cfg);
|
let mut st = self.cur.lock().unwrap();
|
||||||
|
st.file_id = Self::file_identity(&self.path);
|
||||||
|
st.cfg = Some(cfg);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded once on first access.
|
/// The process-wide hooks store (`<config_dir>/hooks.json`), loaded on first access and
|
||||||
|
/// re-loaded whenever the file changes on disk (see [`HooksStore::get`]).
|
||||||
pub fn store() -> &'static HooksStore {
|
pub fn store() -> &'static HooksStore {
|
||||||
static STORE: OnceLock<HooksStore> = OnceLock::new();
|
static STORE: OnceLock<HooksStore> = OnceLock::new();
|
||||||
STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json")))
|
STORE.get_or_init(|| HooksStore::load_from(pf_paths::config_dir().join("hooks.json")))
|
||||||
@@ -860,6 +903,42 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hand_edited_file_reloads_without_restart() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pf-hooks-reload-test-{}-{:p}.json",
|
||||||
|
std::process::id(),
|
||||||
|
&0u8 as *const u8
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
|
||||||
|
let store = HooksStore::load_from(path.clone());
|
||||||
|
assert!(store.get().hooks.is_empty());
|
||||||
|
|
||||||
|
// The documented flow: the operator writes hooks.json by hand and the SAME running
|
||||||
|
// store honors it on the next event — no restart, no PUT.
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
br#"{"hooks":[{"on":"stream.started","run":"true"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.get().hooks.len(), 1, "hand edit applies on next read");
|
||||||
|
assert_eq!(store.get().hooks[0].on, "stream.started");
|
||||||
|
|
||||||
|
// A second edit applies too (length differs, so same-second mtime granularity can't
|
||||||
|
// mask it).
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
br#"{"hooks":[{"on":"stream.started","run":"true"},{"on":"client.*","run":"true"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(store.get().hooks.len(), 2, "second hand edit applies too");
|
||||||
|
|
||||||
|
// Deleting the file removes the hooks.
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
assert!(store.get().hooks.is_empty(), "deleted file = no hooks");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filters_constrain_and_missing_fields_never_match() {
|
fn filters_constrain_and_missing_fields_never_match() {
|
||||||
let ev = sample_event();
|
let ev = sample_event();
|
||||||
|
|||||||
@@ -125,12 +125,13 @@ pub fn ring() -> &'static LogRing {
|
|||||||
RING.get_or_init(LogRing::new)
|
RING.get_or_init(LogRing::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Targets whose DEBUG/TRACE output is steady-state per-packet chatter, not diagnostics — left
|
/// Targets whose DEBUG/TRACE output is steady-state chatter, not diagnostics — left in, they evict
|
||||||
/// in, they evict the entire ring tail (mdns-sd DEBUG-logs every multicast packet it can't parse,
|
/// the entire ring tail: `mdns_sd` DEBUG-logs every multicast packet it can't parse (one chatty
|
||||||
/// so one chatty AirPlay/HomePod device on the LAN floods thousands of entries per hour). The
|
/// AirPlay/HomePod device on the LAN floods thousands of entries per hour), and `wasapi` DEBUG-logs
|
||||||
/// ring keeps their INFO-and-up; stderr under `RUST_LOG` is unaffected. Prefix-matched on module
|
/// the default audio device once a second (the device-watchdog poll). The ring keeps their
|
||||||
/// path boundaries.
|
/// INFO-and-up; the file/stderr filter caps them separately (see `main`'s EnvFilter directives).
|
||||||
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd"];
|
/// Prefix-matched on module path boundaries.
|
||||||
|
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd", "wasapi"];
|
||||||
|
|
||||||
fn is_noisy_debug(target: &str) -> bool {
|
fn is_noisy_debug(target: &str) -> bool {
|
||||||
NOISY_DEBUG_TARGETS.iter().any(|t| {
|
NOISY_DEBUG_TARGETS.iter().any(|t| {
|
||||||
@@ -140,6 +141,25 @@ fn is_noisy_debug(target: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Init the `log`→`tracing` bridge and install `subscriber` as the global default. Replaces
|
||||||
|
/// `SubscriberInitExt::init()` (which auto-inits the bridge with no crate filtering) so we can
|
||||||
|
/// **drop the `wasapi` crate's records at the bridge**: it polls the default audio device ~1×/s
|
||||||
|
/// and `log::debug!`s it, and those bridged events carry the bridge shim target at *filter* time,
|
||||||
|
/// so a downstream level/target filter on the file layer can't catch them (the ring can, in
|
||||||
|
/// `on_event`, via `normalized_metadata` — but the fmt layer filters pre-event). `ignore_crate`
|
||||||
|
/// stops them at the source, before they ever become tracing events, so neither sink sees them.
|
||||||
|
/// The bridge max-level stays DEBUG so every *other* `log`-crate dependency still reaches the ring.
|
||||||
|
pub fn install_global<S>(subscriber: S)
|
||||||
|
where
|
||||||
|
S: tracing::Subscriber + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let _ = tracing_log::LogTracer::builder()
|
||||||
|
.with_max_level(log::LevelFilter::Debug)
|
||||||
|
.ignore_crate("wasapi")
|
||||||
|
.init();
|
||||||
|
let _ = tracing::subscriber::set_global_default(subscriber);
|
||||||
|
}
|
||||||
|
|
||||||
/// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a
|
/// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a
|
||||||
/// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at
|
/// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at
|
||||||
/// `info` (remote debugging must not require a restart with a different env).
|
/// `info` (remote debugging must not require a restart with a different env).
|
||||||
|
|||||||
@@ -117,18 +117,19 @@ fn main() {
|
|||||||
// A second layer tees DEBUG-and-up into the in-memory ring served by GET /api/v1/logs —
|
// A second layer tees DEBUG-and-up into the in-memory ring served by GET /api/v1/logs —
|
||||||
// deliberately not gated by RUST_LOG, so console-side debugging never needs a restart.
|
// deliberately not gated by RUST_LOG, so console-side debugging never needs a restart.
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
|
||||||
use tracing_subscriber::Layer;
|
use tracing_subscriber::Layer;
|
||||||
|
log_capture::install_global(
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
log_capture::RingLayer
|
||||||
|
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.with_filter(filter),
|
.with_filter(filter),
|
||||||
)
|
),
|
||||||
.init();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
|
// Tee every panic through `tracing` BEFORE the default hook: a panicking thread otherwise
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ mod hooks;
|
|||||||
mod host;
|
mod host;
|
||||||
mod library;
|
mod library;
|
||||||
mod native;
|
mod native;
|
||||||
|
mod plugins;
|
||||||
mod session;
|
mod session;
|
||||||
mod shared;
|
mod shared;
|
||||||
mod stats;
|
mod stats;
|
||||||
@@ -223,7 +224,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
))
|
))
|
||||||
.routes(routes!(stats::logs_get))
|
.routes(routes!(stats::logs_get))
|
||||||
.routes(routes!(events::stream_events))
|
.routes(routes!(events::stream_events))
|
||||||
.routes(routes!(hooks::get_hooks, hooks::set_hooks)),
|
.routes(routes!(hooks::get_hooks, hooks::set_hooks))
|
||||||
|
.routes(routes!(plugins::list_plugins))
|
||||||
|
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
|
||||||
|
.routes(routes!(plugins::get_ui_credential)),
|
||||||
)
|
)
|
||||||
.split_for_parts()
|
.split_for_parts()
|
||||||
}
|
}
|
||||||
@@ -261,6 +265,7 @@ pub fn openapi_json() -> String {
|
|||||||
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
||||||
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
|
(name = "events", description = "Host lifecycle events: an SSE stream (client/session/stream lifecycle, pairing, displays, library, host) with Last-Event-ID resume and server-side kind filters"),
|
||||||
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
(name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"),
|
||||||
|
(name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
//! Plugin registry (plugin-ui-surface design): an in-memory, lease-based directory of running
|
||||||
|
//! `punktfunk-plugin-*` processes and the loopback UI each one serves.
|
||||||
|
//!
|
||||||
|
//! A plugin (an out-of-process script under the scripting runner, RFC §8) that wants a UI serves
|
||||||
|
//! it on a **loopback** port behind a per-boot secret, then **registers** here — `{title, ui:{port,
|
||||||
|
//! secret, icon}}` — over the admin/loopback lane it already holds via the SDK. The web console
|
||||||
|
//! reads [`list_plugins`] to grow a nav entry and reverse-proxies to the port (fetching the secret
|
||||||
|
//! from [`get_ui_credential`] server-side, never exposing it to the browser). The host itself never
|
||||||
|
//! dials the plugin, never health-checks it, and never persists any of this: it is a phone book with
|
||||||
|
//! expiry.
|
||||||
|
//!
|
||||||
|
//! Lease model (design §3, D8): a registration lives for [`LEASE_TTL`]; the plugin renews with the
|
||||||
|
//! same idempotent `PUT` every 30 s. Expiry is **lazy** — a crashed plugin's entry simply stops
|
||||||
|
//! listing once stale; there is no reaper task and nothing to persist across a host restart (the
|
||||||
|
//! supervised plugin re-registers on its next tick). Every consumer dials `127.0.0.1:<port>` only —
|
||||||
|
//! a registration stores a *port*, never an address, so it can never point the proxy elsewhere (D5).
|
||||||
|
//!
|
||||||
|
//! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`]
|
||||||
|
//! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every
|
||||||
|
//! other mutation. LAN clients have no business here.
|
||||||
|
|
||||||
|
use super::shared::*;
|
||||||
|
use crate::events::{emit, EventKind};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// How long a registration stays live after its last renewal. The SDK helper renews every 30 s, so
|
||||||
|
/// this tolerates two missed ticks before a plugin drops out of the listing.
|
||||||
|
const LEASE_TTL: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- wire shapes
|
||||||
|
|
||||||
|
/// A plugin's UI surface as it registers it. Carries the secret — this shape is only ever a request
|
||||||
|
/// body, never a response ([`PluginUiPublic`] is the secret-free view).
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginUi {
|
||||||
|
/// The **loopback** port the plugin serves its UI on. The host and console only ever dial
|
||||||
|
/// `127.0.0.1:<port>`; a registration can never carry a hostname.
|
||||||
|
pub port: u16,
|
||||||
|
/// Per-boot shared secret the console proxy must present (as `Authorization: Bearer`) on every
|
||||||
|
/// request to the plugin's UI server. Rotated whenever the plugin restarts.
|
||||||
|
pub secret: String,
|
||||||
|
/// Optional lucide icon name for the console nav entry (`^[a-z0-9-]{1,48}$`).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register/renew body for `PUT /plugins/{id}`.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginRegistration {
|
||||||
|
/// Human-readable title for the console nav entry (1–64 chars; control chars stripped).
|
||||||
|
pub title: String,
|
||||||
|
/// Optional plugin version, purely informational (≤32 chars).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// Present iff the plugin serves a UI surface. A registration with no `ui` is a liveness/phone-book
|
||||||
|
/// entry only (e.g. a future runner-management listing) and grows no nav entry.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui: Option<PluginUi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The secret-free view of a plugin's UI surface — what [`list_plugins`] returns to the browser.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginUiPublic {
|
||||||
|
pub port: u16,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in `GET /plugins`. **Never carries the secret** — the browser learns a plugin exists
|
||||||
|
/// and has a UI, nothing that lets it reach the plugin directly (it goes through the console proxy).
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct PluginSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui: Option<PluginUiPublic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /plugins/{id}/ui-credential` — the console proxy's server-side lookup (bearer + loopback).
|
||||||
|
/// This is the only endpoint that returns a secret; the console BFF denylists it from the browser.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub(crate) struct UiCredential {
|
||||||
|
pub port: u16,
|
||||||
|
pub secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- registry core
|
||||||
|
|
||||||
|
/// The stored UI surface (internal — parsed + validated, no wire derives).
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct StoredUi {
|
||||||
|
port: u16,
|
||||||
|
secret: String,
|
||||||
|
icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One live registration. `expires_at` is a **monotonic** [`Instant`] (immune to wall-clock jumps).
|
||||||
|
struct Stored {
|
||||||
|
title: String,
|
||||||
|
version: Option<String>,
|
||||||
|
ui: Option<StoredUi>,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
/// Do the operator-visible fields match (ignoring the lease clock)? A pure lease renewal leaves
|
||||||
|
/// these unchanged and emits no event; a restart (new secret) or a re-scan (new title/icon) does.
|
||||||
|
fn public_eq(&self, title: &str, version: &Option<String>, ui: &Option<StoredUi>) -> bool {
|
||||||
|
self.title == title && self.version == *version && self.ui == *ui
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide plugin registry.
|
||||||
|
pub(crate) struct PluginRegistry {
|
||||||
|
inner: RwLock<HashMap<String, Stored>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated registration ready to store (title trimmed, ui fields checked).
|
||||||
|
struct Valid {
|
||||||
|
title: String,
|
||||||
|
version: Option<String>,
|
||||||
|
ui: Option<StoredUi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginRegistry {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or renew `id`. Returns `true` when an operator-visible field changed (new plugin,
|
||||||
|
/// restart, or re-scan) — the signal the caller emits `plugins.changed` on. A pure renewal
|
||||||
|
/// returns `false`.
|
||||||
|
fn upsert(&self, id: &str, v: Valid) -> bool {
|
||||||
|
let expires_at = Instant::now() + LEASE_TTL;
|
||||||
|
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let changed = match map.get(id) {
|
||||||
|
// An *expired* prior entry counts as a change (it had stopped listing).
|
||||||
|
Some(prev) => !prev.is_live() || !prev.public_eq(&v.title, &v.version, &v.ui),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
map.insert(
|
||||||
|
id.to_string(),
|
||||||
|
Stored {
|
||||||
|
title: v.title,
|
||||||
|
version: v.version,
|
||||||
|
ui: v.ui,
|
||||||
|
expires_at,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current listing (sorted by title, then id), plus the ids of entries that had expired and
|
||||||
|
/// were pruned by this call — the caller emits `plugins.changed` for those. Prunes under the
|
||||||
|
/// write lock so a stale entry is reaped exactly once.
|
||||||
|
fn snapshot(&self) -> (Vec<PluginSummary>, Vec<String>) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut expired: Vec<String> = map
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| now >= s.expires_at)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
for id in &expired {
|
||||||
|
map.remove(id);
|
||||||
|
}
|
||||||
|
expired.sort();
|
||||||
|
let mut live: Vec<PluginSummary> = map
|
||||||
|
.iter()
|
||||||
|
.map(|(id, s)| PluginSummary {
|
||||||
|
id: id.clone(),
|
||||||
|
title: s.title.clone(),
|
||||||
|
version: s.version.clone(),
|
||||||
|
ui: s.ui.as_ref().map(|u| PluginUiPublic {
|
||||||
|
port: u.port,
|
||||||
|
icon: u.icon.clone(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
live.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
|
||||||
|
(live, expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `{port, secret}` for a live plugin's UI, or `None` if unknown/expired/UI-less. Does not
|
||||||
|
/// prune (a read path) — a stale entry is reaped by the next [`snapshot`](Self::snapshot).
|
||||||
|
fn credential(&self, id: &str) -> Option<UiCredential> {
|
||||||
|
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let s = map.get(id)?;
|
||||||
|
if !s.is_live() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ui = s.ui.as_ref()?;
|
||||||
|
Some(UiCredential {
|
||||||
|
port: ui.port,
|
||||||
|
secret: ui.secret.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove `id`. Returns `true` if a **live** entry existed (a clean deregister); removing an
|
||||||
|
/// already-expired or unknown id returns `false` (nothing to announce).
|
||||||
|
fn remove(&self, id: &str) -> bool {
|
||||||
|
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
|
||||||
|
map.remove(id).is_some_and(|s| s.is_live())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
fn is_live(&self) -> bool {
|
||||||
|
Instant::now() < self.expires_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide registry singleton (the [`crate::events::bus`] shape).
|
||||||
|
pub(crate) fn registry() -> &'static PluginRegistry {
|
||||||
|
static REG: OnceLock<PluginRegistry> = OnceLock::new();
|
||||||
|
REG.get_or_init(PluginRegistry::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- validation
|
||||||
|
|
||||||
|
/// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK
|
||||||
|
/// enforces, so a plugin's registration id always matches its package name.
|
||||||
|
fn valid_plugin_id(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 64
|
||||||
|
&& id.as_bytes()[0].is_ascii_lowercase()
|
||||||
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip control characters (defense against a title/version smuggling terminal escapes or newlines
|
||||||
|
/// into a log line or the console nav), then trim.
|
||||||
|
fn sanitize(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a registration body into the internal [`Valid`] form, or a human-readable reason.
|
||||||
|
fn validate(reg: PluginRegistration) -> Result<Valid, String> {
|
||||||
|
let title = sanitize(®.title);
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err("title must not be empty".into());
|
||||||
|
}
|
||||||
|
if title.chars().count() > 64 {
|
||||||
|
return Err("title must be at most 64 characters".into());
|
||||||
|
}
|
||||||
|
let version = match reg.version {
|
||||||
|
Some(v) => {
|
||||||
|
let v = sanitize(&v);
|
||||||
|
if v.chars().count() > 32 {
|
||||||
|
return Err("version must be at most 32 characters".into());
|
||||||
|
}
|
||||||
|
(!v.is_empty()).then_some(v)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let ui = match reg.ui {
|
||||||
|
Some(u) => Some(validate_ui(u)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Valid { title, version, ui })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_ui(u: PluginUi) -> Result<StoredUi, String> {
|
||||||
|
if u.port < 1024 {
|
||||||
|
return Err("ui.port must be a non-privileged port (>= 1024)".into());
|
||||||
|
}
|
||||||
|
let n = u.secret.len();
|
||||||
|
if !(16..=128).contains(&n) {
|
||||||
|
return Err("ui.secret must be 16–128 characters".into());
|
||||||
|
}
|
||||||
|
if !u
|
||||||
|
.secret
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||||
|
{
|
||||||
|
return Err("ui.secret must be [A-Za-z0-9_-]".into());
|
||||||
|
}
|
||||||
|
let icon = match u.icon {
|
||||||
|
Some(icon) => {
|
||||||
|
let ok = (1..=48).contains(&icon.len())
|
||||||
|
&& icon
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
|
||||||
|
if !ok {
|
||||||
|
return Err("ui.icon must be a lucide name ([a-z0-9-], 1–48 chars)".into());
|
||||||
|
}
|
||||||
|
Some(icon)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(StoredUi {
|
||||||
|
port: u.port,
|
||||||
|
secret: u.secret,
|
||||||
|
icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- handlers
|
||||||
|
|
||||||
|
/// Register or renew a plugin
|
||||||
|
///
|
||||||
|
/// Upserts the plugin's directory entry and renews its lease (TTL 90 s). Idempotent: a plugin PUTs
|
||||||
|
/// this every ~30 s while it runs. The optional `ui` block declares a loopback UI surface the console
|
||||||
|
/// will proxy and add to its nav. Emits `plugins.changed` when an operator-visible field changed
|
||||||
|
/// (first registration, restart, or re-scan) — a pure renewal is silent.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/plugins/{id}",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "registerPlugin",
|
||||||
|
params(("id" = String, Path, description = "The plugin id (its `definePlugin` name: `[a-z][a-z0-9-]*`)")),
|
||||||
|
request_body = PluginRegistration,
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Registered / renewed"),
|
||||||
|
(status = BAD_REQUEST, description = "Invalid id or registration", body = ApiError),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn register_plugin(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
ApiJson(reg): ApiJson<PluginRegistration>,
|
||||||
|
) -> Response {
|
||||||
|
if !valid_plugin_id(&id) {
|
||||||
|
return api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid plugin id (expected kebab-case `[a-z][a-z0-9-]*`, ≤64)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let valid = match validate(reg) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return api_error(StatusCode::BAD_REQUEST, &e),
|
||||||
|
};
|
||||||
|
if registry().upsert(&id, valid) {
|
||||||
|
tracing::info!(plugin = %id, "plugin registered");
|
||||||
|
emit(EventKind::PluginsChanged { id });
|
||||||
|
}
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List registered plugins
|
||||||
|
///
|
||||||
|
/// The live plugin directory (lease not expired), sorted by title. **Secret-free**: each entry
|
||||||
|
/// reports its id, title, optional version, and — for plugins that serve one — a UI descriptor
|
||||||
|
/// (loopback port + icon). The console renders these as nav entries and proxies to the port; it
|
||||||
|
/// fetches the secret separately, server-side.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/plugins",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "listPlugins",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Live plugin registrations", body = [PluginSummary]),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn list_plugins() -> Json<Vec<PluginSummary>> {
|
||||||
|
let (plugins, expired) = registry().snapshot();
|
||||||
|
// Lazy expiry: an entry that aged out is reaped here (exactly once) and announced, so a consumer
|
||||||
|
// watching the event stream sees the departure even though nothing actively deregistered it.
|
||||||
|
for id in expired {
|
||||||
|
tracing::info!(plugin = %id, "plugin lease expired");
|
||||||
|
emit(EventKind::PluginsChanged { id });
|
||||||
|
}
|
||||||
|
Json(plugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a plugin UI's proxy credential
|
||||||
|
///
|
||||||
|
/// Returns `{port, secret}` for a live plugin's loopback UI — the console proxy's server-side lookup.
|
||||||
|
/// Bearer + loopback only (like every mutation), and additionally excluded from the console's browser
|
||||||
|
/// passthrough: the secret never reaches a browser.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/plugins/{id}/ui-credential",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "getPluginUiCredential",
|
||||||
|
params(("id" = String, Path, description = "The plugin id")),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The proxy credential", body = UiCredential),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
(status = NOT_FOUND, description = "No live plugin with that id, or it serves no UI", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn get_ui_credential(Path(id): Path<String>) -> Response {
|
||||||
|
match registry().credential(&id) {
|
||||||
|
Some(cred) => Json(cred).into_response(),
|
||||||
|
None => api_error(StatusCode::NOT_FOUND, "no live plugin UI with that id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deregister a plugin
|
||||||
|
///
|
||||||
|
/// The clean-shutdown path: removes the plugin's directory entry immediately (the SDK helper calls
|
||||||
|
/// this from its scope finalizer on `SIGTERM`). Emits `plugins.changed` when a live entry was
|
||||||
|
/// removed. Idempotent — deleting an unknown/expired id is a no-op `204`.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/plugins/{id}",
|
||||||
|
tag = "plugins",
|
||||||
|
operation_id = "deregisterPlugin",
|
||||||
|
params(("id" = String, Path, description = "The plugin id")),
|
||||||
|
responses(
|
||||||
|
(status = NO_CONTENT, description = "Deregistered (or already absent)"),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_plugin(Path(id): Path<String>) -> Response {
|
||||||
|
if registry().remove(&id) {
|
||||||
|
tracing::info!(plugin = %id, "plugin deregistered");
|
||||||
|
emit(EventKind::PluginsChanged { id });
|
||||||
|
}
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn reg(title: &str, port: u16, secret: &str) -> PluginRegistration {
|
||||||
|
PluginRegistration {
|
||||||
|
title: title.into(),
|
||||||
|
version: None,
|
||||||
|
ui: Some(PluginUi {
|
||||||
|
port,
|
||||||
|
secret: secret.into(),
|
||||||
|
icon: Some("gamepad-2".into()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET: &str = "abcdefghijklmnop0123"; // 20 chars, valid alphabet
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn id_validation() {
|
||||||
|
assert!(valid_plugin_id("rom-manager"));
|
||||||
|
assert!(valid_plugin_id("a"));
|
||||||
|
assert!(valid_plugin_id("x9"));
|
||||||
|
assert!(!valid_plugin_id("")); // empty
|
||||||
|
assert!(!valid_plugin_id("9lives")); // must start with a letter
|
||||||
|
assert!(!valid_plugin_id("-lead")); // must start with a letter
|
||||||
|
assert!(!valid_plugin_id("Rom")); // no uppercase
|
||||||
|
assert!(!valid_plugin_id("rom_manager")); // no underscore
|
||||||
|
assert!(!valid_plugin_id(&"a".repeat(65))); // too long
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_validation() {
|
||||||
|
assert!(validate(reg("ROM Manager", 49321, SECRET)).is_ok());
|
||||||
|
// control chars stripped from the title
|
||||||
|
let v = validate(PluginRegistration {
|
||||||
|
title: "Ro\u{7}m\n".into(),
|
||||||
|
version: None,
|
||||||
|
ui: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v.title, "Rom");
|
||||||
|
// privileged port rejected
|
||||||
|
assert!(validate(reg("x", 80, SECRET)).is_err());
|
||||||
|
// short secret rejected
|
||||||
|
assert!(validate(reg("x", 49321, "tooshort")).is_err());
|
||||||
|
// bad secret alphabet rejected
|
||||||
|
assert!(validate(reg("x", 49321, "bad secret with spaces!!")).is_err());
|
||||||
|
// empty title rejected
|
||||||
|
assert!(validate(reg(" ", 49321, SECRET)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_reports_change_but_not_renewal() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
// first registration is a change
|
||||||
|
assert!(r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
|
||||||
|
// identical renewal is not
|
||||||
|
assert!(!r.upsert("p", validate(reg("Title", 49321, SECRET)).unwrap()));
|
||||||
|
// a new secret (restart) is a change
|
||||||
|
assert!(r.upsert(
|
||||||
|
"p",
|
||||||
|
validate(reg("Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
|
||||||
|
));
|
||||||
|
// a new title (re-scan) is a change
|
||||||
|
assert!(r.upsert(
|
||||||
|
"p",
|
||||||
|
validate(reg("New Title", 49321, "ZZZZZZZZZZZZZZZZ")).unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_lists_secret_free_sorted() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert("zeta", validate(reg("Zeta", 50000, SECRET)).unwrap());
|
||||||
|
r.upsert("alpha", validate(reg("Alpha", 50001, SECRET)).unwrap());
|
||||||
|
let (plugins, expired) = r.snapshot();
|
||||||
|
assert!(expired.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
plugins.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["alpha", "zeta"] // sorted by title
|
||||||
|
);
|
||||||
|
// the summary type has no secret field at all — check the credential path carries it
|
||||||
|
assert_eq!(r.credential("alpha").unwrap().secret, SECRET);
|
||||||
|
assert_eq!(r.credential("alpha").unwrap().port, 50001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn credential_absent_for_ui_less_and_unknown() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert(
|
||||||
|
"headless",
|
||||||
|
validate(PluginRegistration {
|
||||||
|
title: "Headless".into(),
|
||||||
|
version: None,
|
||||||
|
ui: None,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert!(r.credential("headless").is_none()); // registered but no UI
|
||||||
|
assert!(r.credential("nope").is_none()); // unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_entries_drop_from_listing_and_credential() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
|
||||||
|
// Force the lease into the past.
|
||||||
|
{
|
||||||
|
let mut map = r.inner.write().unwrap();
|
||||||
|
map.get_mut("p").unwrap().expires_at =
|
||||||
|
Instant::now().checked_sub(Duration::from_secs(1)).unwrap();
|
||||||
|
}
|
||||||
|
assert!(r.credential("p").is_none()); // expired → no credential
|
||||||
|
let (plugins, expired) = r.snapshot();
|
||||||
|
assert!(plugins.is_empty());
|
||||||
|
assert_eq!(expired, vec!["p".to_string()]); // reaped + announced once
|
||||||
|
// second snapshot no longer reports it as freshly-expired
|
||||||
|
assert!(r.snapshot().1.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_reports_live_only() {
|
||||||
|
let r = PluginRegistry::new();
|
||||||
|
r.upsert("p", validate(reg("P", 49321, SECRET)).unwrap());
|
||||||
|
assert!(r.remove("p")); // live → announced
|
||||||
|
assert!(!r.remove("p")); // already gone → silent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,18 @@ async fn cert_auth_is_a_read_only_allowlist() {
|
|||||||
"the client roster {p} must require the bearer token, not just a paired cert"
|
"the client roster {p} must require the bearer token, not just a paired cert"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The plugin directory is admin-only — a paired streaming cert has no business enumerating the
|
||||||
|
// host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3).
|
||||||
|
for p in [
|
||||||
|
"/api/v1/plugins",
|
||||||
|
"/api/v1/plugins/rom-manager/ui-credential",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
send_cert(&app, get_req(p), fp).await,
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"the plugin directory {p} must require the bearer token, not just a paired cert"
|
||||||
|
);
|
||||||
|
}
|
||||||
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
|
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
|
||||||
@@ -574,6 +586,89 @@ async fn idr_requires_an_active_stream() {
|
|||||||
assert!(state.force_idr.load(Ordering::SeqCst));
|
assert!(state.force_idr.load(Ordering::SeqCst));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The plugin registry round-trips through the router: register → list (secret-free) → credential
|
||||||
|
/// (secret present) → deregister. Guards the wiring, auth, and — the security-critical bit — that
|
||||||
|
/// the UI secret never appears in the browser-visible listing (plugin-ui-surface §7, D6).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plugin_registry_roundtrip() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
let id = "test-plugin-roundtrip";
|
||||||
|
let secret = "s3cr3t-abcdefghijkl"; // 19 chars, valid [A-Za-z0-9_-]
|
||||||
|
|
||||||
|
// Register with a UI surface → 204.
|
||||||
|
let (status, _) = send(
|
||||||
|
&app,
|
||||||
|
put_json(
|
||||||
|
&format!("/api/v1/plugins/{id}"),
|
||||||
|
serde_json::json!({
|
||||||
|
"title": "Test Plugin",
|
||||||
|
"ui": { "port": 49321, "secret": secret, "icon": "gamepad-2" }
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// It lists — and the secret appears NOWHERE in the listing body.
|
||||||
|
let (status, body) = send(&app, get_req("/api/v1/plugins")).await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
let mine = body
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["id"] == id)
|
||||||
|
.expect("registered plugin is listed");
|
||||||
|
assert_eq!(mine["title"], "Test Plugin");
|
||||||
|
assert_eq!(mine["ui"]["port"], 49321);
|
||||||
|
assert_eq!(mine["ui"]["icon"], "gamepad-2");
|
||||||
|
assert!(
|
||||||
|
!body.to_string().contains(secret),
|
||||||
|
"the listing must never carry the UI secret"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The credential endpoint (server-side proxy lookup) DOES carry it.
|
||||||
|
let (status, body) = send(
|
||||||
|
&app,
|
||||||
|
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["secret"], secret);
|
||||||
|
assert_eq!(body["port"], 49321);
|
||||||
|
|
||||||
|
// Deregister → gone from the listing, credential 404s.
|
||||||
|
let (status, _) = send(
|
||||||
|
&app,
|
||||||
|
axum::http::Request::delete(format!("/api/v1/plugins/{id}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||||
|
let (_, body) = send(&app, get_req("/api/v1/plugins")).await;
|
||||||
|
assert!(
|
||||||
|
body.as_array().unwrap().iter().all(|p| p["id"] != id),
|
||||||
|
"deregistered plugin must not list"
|
||||||
|
);
|
||||||
|
let (status, _) = send(
|
||||||
|
&app,
|
||||||
|
get_req(&format!("/api/v1/plugins/{id}/ui-credential")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
// A structurally invalid registration is a 400 (privileged port).
|
||||||
|
let (status, _) = send(
|
||||||
|
&app,
|
||||||
|
put_json(
|
||||||
|
&format!("/api/v1/plugins/{id}"),
|
||||||
|
serde_json::json!({ "title": "x", "ui": { "port": 80, "secret": secret } }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
/// The OpenAPI document lists every route with a unique operationId (codegen relies
|
/// The OpenAPI document lists every route with a unique operationId (codegen relies
|
||||||
/// on both), and the checked-in copy is current.
|
/// on both), and the checked-in copy is current.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -201,6 +201,20 @@ fn frame_driven_enabled() -> bool {
|
|||||||
*ON.get_or_init(|| std::env::var("PUNKTFUNK_FRAME_DRIVEN").as_deref() != Ok("0"))
|
*ON.get_or_init(|| std::env::var("PUNKTFUNK_FRAME_DRIVEN").as_deref() != Ok("0"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adaptive pipeline depth (latency plan, from the 2026-07-17 on-glass finding on a `.173` RTX
|
||||||
|
/// 4090): the capturer's pipeline depth of 2 measured **~13 ms of glass-to-glass latency** over
|
||||||
|
/// depth 1 at 60 fps (17 ms → 4 ms) — the AU is ready in µs but depth-2 holds it a whole frame
|
||||||
|
/// interval unpolled while N+1 is submitted. Depth-2 exists to overlap the convert of N+1 with
|
||||||
|
/// the encode of N under GPU contention (the depth-1 ~50 fps collapse), so run **depth-1 by
|
||||||
|
/// default** and escalate to the capturer's max ONLY when the loop can't hold its cadence at
|
||||||
|
/// depth-1 (the contention tell), then stick there for the session (escalate-and-hold — no
|
||||||
|
/// oscillation; de-escalation is a v2 item). `PUNKTFUNK_IDD_ADAPTIVE=0` pins the capturer's full
|
||||||
|
/// depth (the pre-adaptive behaviour). Off when the capturer's max depth is already 1.
|
||||||
|
fn idd_adaptive_enabled() -> bool {
|
||||||
|
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*ON.get_or_init(|| std::env::var("PUNKTFUNK_IDD_ADAPTIVE").as_deref() != Ok("0"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
||||||
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
||||||
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
||||||
@@ -1131,6 +1145,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
Vec<u32>,
|
Vec<u32>,
|
||||||
Vec<u32>,
|
Vec<u32>,
|
||||||
) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||||
|
// Adaptive pipeline depth (see [`idd_adaptive_enabled`]): run depth-1 for latency and
|
||||||
|
// escalate to the capturer's max on sustained cadence overrun. `cur_depth` is the live
|
||||||
|
// target (clamped to the capturer's current max each iteration — a rebuild can change it);
|
||||||
|
// `behind_score` is a leaky bucket over the "fell behind the cadence deadline" signal;
|
||||||
|
// `depth_frames` skips the startup warmup so first-frame bring-up cost can't false-escalate.
|
||||||
|
let mut cur_depth: usize = 1;
|
||||||
|
let mut behind_score: u32 = 0;
|
||||||
|
let mut depth_frames: u64 = 0;
|
||||||
|
// ~20 net behind-frames (≈0.3 s sustained) escalates; a lone hitch decays away. Warmup skips
|
||||||
|
// the first ~1 s so bring-up (display acquire, encoder open) never triggers it.
|
||||||
|
const DEPTH_ESCALATE: u32 = 20;
|
||||||
|
const DEPTH_BEHIND_CAP: u32 = 60;
|
||||||
|
const DEPTH_WARMUP_FRAMES: u64 = 60;
|
||||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||||
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
||||||
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
||||||
@@ -1718,7 +1745,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
}
|
}
|
||||||
// How deep to pipeline (1 = synchronous submit→poll, the original behaviour). The IDD-push
|
// How deep to pipeline (1 = synchronous submit→poll, the original behaviour). The IDD-push
|
||||||
// capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1.
|
// capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1.
|
||||||
let depth = capturer.pipeline_depth().max(1);
|
// Adaptive (default): start at 1 for latency, `cur_depth` escalates on sustained overrun (the
|
||||||
|
// tail below). Pinned to the capturer's max when adaptive is off or the max is already 1.
|
||||||
|
let max_depth = capturer.pipeline_depth().max(1);
|
||||||
|
let depth = if idd_adaptive_enabled() {
|
||||||
|
cur_depth.clamp(1, max_depth)
|
||||||
|
} else {
|
||||||
|
max_depth
|
||||||
|
};
|
||||||
let submit_ns = now_ns();
|
let submit_ns = now_ns();
|
||||||
// Wire pts: a fresh frame anchors at its capture-delivery stamp (`CapturedFrame.pts_ns`,
|
// Wire pts: a fresh frame anchors at its capture-delivery stamp (`CapturedFrame.pts_ns`,
|
||||||
// stamped when the capture thread handed it over) so client-measured latency covers
|
// stamped when the capture thread handed it over) so client-measured latency covers
|
||||||
@@ -1934,6 +1968,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
"encode stall detected — encoder rebuilt in place, forcing an IDR");
|
"encode stall detected — encoder rebuilt in place, forcing an IDR");
|
||||||
last_au_at = std::time::Instant::now();
|
last_au_at = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
|
// Adaptive-depth escalate signal (measured BEFORE the trailing sleep): "behind" = the
|
||||||
|
// frame's work overran its cadence deadline `next`, so the trailing sleep would be
|
||||||
|
// zero/negative. At depth-1 that means the synchronous poll (encode + WDDM wait) can't
|
||||||
|
// fit a frame interval — the contention case pipelining is for — so escalate to the
|
||||||
|
// capturer's max and hold there. Leaky bucket + warmup skip reject one-off hitches and
|
||||||
|
// bring-up. Once escalated, `cur_depth` stays (no de-escalation in v1).
|
||||||
|
if idd_adaptive_enabled() && cur_depth < max_depth {
|
||||||
|
depth_frames += 1;
|
||||||
|
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||||
|
let behind = std::time::Instant::now() >= next;
|
||||||
|
behind_score = if behind {
|
||||||
|
(behind_score + 1).min(DEPTH_BEHIND_CAP)
|
||||||
|
} else {
|
||||||
|
behind_score.saturating_sub(1)
|
||||||
|
};
|
||||||
|
if behind_score >= DEPTH_ESCALATE {
|
||||||
|
cur_depth = max_depth;
|
||||||
|
tracing::info!(
|
||||||
|
depth = cur_depth,
|
||||||
|
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
||||||
|
(GPU contention); pipelining for the rest of the session (latency \
|
||||||
|
trade for throughput)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if frame_driven_enabled() && capturer.supports_arrival_wait() {
|
if frame_driven_enabled() && capturer.supports_arrival_wait() {
|
||||||
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
|
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
|
||||||
// SAMPLING (which holds a frame that arrived just after the previous sample for up
|
// SAMPLING (which holds a frame that arrived just after the previous sample for up
|
||||||
|
|||||||
@@ -153,37 +153,36 @@ fn rotate_if_large(path: &std::path::Path) {
|
|||||||
/// supervisor serves no mgmt API itself, but the layer is harmless and keeps both inits uniform.
|
/// supervisor serves no mgmt API itself, but the layer is harmless and keeps both inits uniform.
|
||||||
pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
|
pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
|
||||||
use tracing_subscriber::Layer;
|
use tracing_subscriber::Layer;
|
||||||
let ring =
|
let ring =
|
||||||
crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG);
|
crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG);
|
||||||
let log_path = service_log_path();
|
let log_path = service_log_path();
|
||||||
rotate_if_large(&log_path);
|
rotate_if_large(&log_path);
|
||||||
|
// `install_global` inits the log bridge with `ignore_crate("wasapi")` (see its doc) and sets
|
||||||
|
// the subscriber global — replacing `SubscriberInitExt::init`, which would bridge every crate.
|
||||||
match std::fs::OpenOptions::new()
|
match std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
.open(log_path)
|
.open(log_path)
|
||||||
{
|
{
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
tracing_subscriber::registry()
|
crate::log_capture::install_global(
|
||||||
.with(ring)
|
tracing_subscriber::registry().with(ring).with(
|
||||||
.with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_writer(move || file.try_clone().expect("clone service log handle"))
|
.with_writer(move || file.try_clone().expect("clone service log handle"))
|
||||||
.with_filter(filter),
|
.with_filter(filter),
|
||||||
)
|
),
|
||||||
.init();
|
);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing_subscriber::registry()
|
crate::log_capture::install_global(
|
||||||
.with(ring)
|
tracing_subscriber::registry().with(ring).with(
|
||||||
.with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.with_filter(filter),
|
.with_filter(filter),
|
||||||
)
|
),
|
||||||
.init();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ and nothing you configure here runs anywhere near the streaming path.
|
|||||||
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
|
| `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane |
|
||||||
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
|
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
|
||||||
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
|
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
|
||||||
|
| `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
|
||||||
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
|
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
|
||||||
|
|
||||||
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ A headless CLI path exists for scripting/measurement:
|
|||||||
```sh
|
```sh
|
||||||
punktfunk-client # open the WinUI 3 window (host list / settings)
|
punktfunk-client # open the WinUI 3 window (host list / settings)
|
||||||
punktfunk-client --discover # list hosts on the network
|
punktfunk-client --discover # list hosts on the network
|
||||||
punktfunk-client --headless --connect <host>:9777 # no window: connect, count frames, print stats
|
punktfunk-client --headless --speed-test --connect <host>:9777 # no window: probe the link, print measured/recommended bitrate
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below).
|
Prefer the broadest compatibility, or no install? **Moonlight** also streams to Windows (see below).
|
||||||
@@ -121,13 +121,13 @@ punktfunk-probe --connect <host>:9777 --pin <fp> # connect to one
|
|||||||
|
|
||||||
| You're streaming to… | Use |
|
| You're streaming to… | Use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| A Mac, iPhone, iPad, or Apple TV | The **Apple app** |
|
| A Mac, iPhone, iPad, or Apple TV | The **[Apple app](#apple-app-mac-iphone-ipad-apple-tv)** |
|
||||||
| A Linux desktop or laptop | **`punktfunk-client`** (GTK4) |
|
| A Linux desktop or laptop | **[`punktfunk-client`](#linux-desktop-client-gtk4)** (GTK4) |
|
||||||
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the GTK4 client in Desktop Mode |
|
| A **Steam Deck** | The **[Decky plugin](/docs/steam-deck)** in Gaming Mode, or the [GTK4 client](#linux-desktop-client-gtk4) in Desktop Mode |
|
||||||
| An Android phone or TV | The **Android app** |
|
| An Android phone or TV | The **[Android app](#android-app-phone--android-tv)** |
|
||||||
| Windows | The native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
|
| Windows | The native **[`punktfunk-client`](#windows-desktop-client)** (signed MSIX) or **[Moonlight](/docs/moonlight)** |
|
||||||
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **Moonlight** |
|
| An **LG webOS TV** | The community **[`pf-webos`](https://github.com/dyptan-io/pf-webos)** client, or **[Moonlight](/docs/moonlight)** |
|
||||||
| A browser, another smart TV, or any other device | **Moonlight** |
|
| A browser, another smart TV, or any other device | **[Moonlight](/docs/moonlight)** |
|
||||||
| Automated tests / latency measurement | **`punktfunk-probe`** (headless) |
|
| Automated tests / latency measurement | **[`punktfunk-probe`](#linux-reference-client-headless)** (headless) |
|
||||||
|
|
||||||
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
|
Whichever you choose, the first connection needs a one-time [pairing](/docs/pairing).
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
|||||||
| Setting | Values | Meaning |
|
| Setting | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
|
| `PUNKTFUNK_FEC_PCT` | `N` (percent) | Forward-error-correction redundancy for lossy links (the default is sensible for a normal LAN). Higher = more loss-resilient, more bandwidth. |
|
||||||
| `PUNKTFUNK_10BIT` | `1` | HEVC Main10 / HDR. Honored only when the client also advertises 10-bit. **Windows host only** (the Linux host stays 8-bit). |
|
| `PUNKTFUNK_10BIT` | `1` · `0` *(default on)* | HEVC Main10 / HDR. **On by default** — the host permits 10-bit; a session goes 10-bit only when the client advertises it (behind the client's HDR setting). Set `0` to force 8-bit. **Windows host only** (the Linux host stays 8-bit). |
|
||||||
| `PUNKTFUNK_444` | `1` | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
|
| `PUNKTFUNK_444` | `1` · `0` *(default on)* | Full-chroma HEVC 4:4:4 (Range Extensions) — sharper text/desktop, no chroma loss. **On by default** on the host; the client's own 4:4:4 setting (default off) is the real switch. Set `0` to force 4:2:0. **punktfunk/1 native only** (Moonlight stays 4:2:0), HEVC-only, honored only when the client advertises 4:4:4 **and** the GPU supports it (probed; NVENC is the validated path — VAAPI/AMF/QSV decline). Independent of 10-bit. |
|
||||||
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
| `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. |
|
||||||
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
| `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. |
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ notes for context.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
|
| `PUNKTFUNK_GSO` | `1` · `0` | UDP Generic Segmentation Offload on the send path (coalesce a frame's packets into kernel super-buffers) — cuts send CPU ~30%, but its line-rate packet trains can cost delivered throughput on constrained links (measured on a 2.5GbE hop). Off by default until send pacing spaces the super-buffers; set `1` to opt in (auto-falls back to `sendmmsg` on kernels/paths without support). |
|
||||||
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
|
| `PUNKTFUNK_SPLIT_ENCODE` | `0`/`disable` · `1`/`auto` · `2` · `3` | NVENC N-way split-encode for very high pixel rates (5K@240). `auto` picks automatically above ~1 Gpix/s. |
|
||||||
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `high`; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
| `PUNKTFUNK_GPU_PRIORITY_CLASS` | `off` · `normal` · `high` · `realtime` · `auto` | **(Windows)** GPU scheduling priority for capture/encode under a GPU-saturating game. Default `auto` (starts `high`, upgrades to `realtime` when it's safe — e.g. HAGS off); `high` pins the static pre-gate behaviour; `realtime` is the strongest lever but can freeze NVENC on some setups. |
|
||||||
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
|
||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ GPU path** that keeps latency low even at high resolutions and frame rates.
|
|||||||
|
|
||||||
punktfunk speaks two protocols over the same host:
|
punktfunk speaks two protocols over the same host:
|
||||||
|
|
||||||
- **GameStream** — the protocol Moonlight uses. Any [Moonlight](/docs/moonlight) client connects with
|
- **GameStream** — the protocol Moonlight uses. Start the host with `--gamestream` and any
|
||||||
no special software. This is the most compatible way in.
|
[Moonlight](/docs/moonlight) client connects with no special software. This is the most compatible way in.
|
||||||
- **punktfunk/1 (native)** — a purpose-built protocol with a QUIC control channel and a UDP data
|
- **punktfunk/1 (native)** — a purpose-built protocol with a QUIC control channel and a UDP data
|
||||||
channel hardened with forward error correction and encryption. It's lower-latency and more resilient
|
channel hardened with forward error correction and encryption. It's lower-latency and more resilient
|
||||||
on imperfect networks, and it's what the [native clients](/docs/clients) (Apple, Linux, Windows,
|
on imperfect networks, and it's what the [native clients](/docs/clients) (Apple, Linux, Windows,
|
||||||
Android) use.
|
Android) use.
|
||||||
|
|
||||||
Both run from a single host process, so you don't choose up front — Moonlight clients use GameStream,
|
The native `punktfunk/1` plane runs by default (the secure default); add `--gamestream` and both planes
|
||||||
the native clients use punktfunk/1.
|
serve from a single host process — Moonlight clients use GameStream, the native clients use punktfunk/1.
|
||||||
|
|
||||||
## Pairing and trust
|
## Pairing and trust
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ It's built for the things that make streaming feel native:
|
|||||||
plug to deal with, even on the secure desktop.
|
plug to deal with, even on the secure desktop.
|
||||||
- **Low latency, GPU end to end.** Frames go straight from the compositor to the GPU encoder
|
- **Low latency, GPU end to end.** Frames go straight from the compositor to the GPU encoder
|
||||||
(NVENC) with zero CPU copies, and over a transport tuned for responsiveness rather than throughput.
|
(NVENC) with zero CPU copies, and over a transport tuned for responsiveness rather than throughput.
|
||||||
- **Works with the apps you already have.** punktfunk speaks the GameStream protocol, so any
|
- **Works with the apps you already have.** punktfunk also speaks the GameStream protocol, so with
|
||||||
**Moonlight** client connects out of the box — and a faster **native protocol** with dedicated apps
|
`--gamestream` any **Moonlight** client connects — alongside a faster **native protocol** with
|
||||||
for **macOS, iOS, tvOS, Linux, Windows, and Android**.
|
dedicated apps for **macOS, iOS, tvOS, Linux, Windows, and Android**.
|
||||||
- **Secure by default.** Hosts require a one-time PIN pairing; after that, devices reconnect on a
|
- **Secure by default.** Hosts require a one-time PIN pairing; after that, devices reconnect on a
|
||||||
pinned identity. No accounts, no cloud.
|
pinned identity. No accounts, no cloud.
|
||||||
|
|
||||||
|
|||||||
@@ -83,15 +83,17 @@ The Windows client ships as a **signed MSIX** in the package registry. Builds us
|
|||||||
certificate, so you import that certificate once before Windows will install the package.
|
certificate, so you import that certificate once before Windows will install the package.
|
||||||
|
|
||||||
1. Open the [packages page](https://git.unom.io/unom/-/packages) (generic group), find
|
1. Open the [packages page](https://git.unom.io/unom/-/packages) (generic group), find
|
||||||
**`punktfunk-client-windows`**, and download the newest **`.msix`** and its matching **`.cer`**.
|
**`punktfunk-client-windows`**, and download the newest **`.msix`** and its matching **`.cer`** for
|
||||||
|
your CPU — the artifacts are arch-suffixed (`…_x64.msix` / `…_arm64.msix`).
|
||||||
2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is
|
2. **Trust the publisher certificate**, then install. The MSIX won't install until the certificate is
|
||||||
trusted — but it's the **same certificate for every release**, so this is genuinely one-time and
|
trusted — but it's the **same certificate for every release**, so this is genuinely one-time and
|
||||||
later updates need nothing. In an **admin** PowerShell:
|
later updates need nothing. In an **admin** PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Import-Certificate -FilePath .\punktfunk-client-windows.cer `
|
# use the _arm64 files instead on an Arm device
|
||||||
|
Import-Certificate -FilePath .\punktfunk-client-windows_x64.cer `
|
||||||
-CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
-CertStoreLocation Cert:\LocalMachine\TrustedPeople
|
||||||
Add-AppxPackage .\punktfunk-client-windows.msix
|
Add-AppxPackage .\punktfunk-client-windows_x64.msix
|
||||||
```
|
```
|
||||||
|
|
||||||
If Windows reports a missing dependency, install the
|
If Windows reports a missing dependency, install the
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ punktfunk also runs as a native host on **Windows 11 22H2+ (x64)**, shipped as a
|
|||||||
installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
|
installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
|
||||||
|
|
||||||
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
|
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
|
||||||
**`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**.
|
**`punktfunk-host-setup-<ver>.exe`** and the matching **`punktfunk-host-windows_<ver>.cer`**.
|
||||||
2. **Trust the publisher certificate once.** The installer is signed with a self-signed certificate
|
2. **Trust the publisher certificate once.** The installer is signed with a self-signed certificate
|
||||||
whose public `.cer` is published next to it — the **same certificate for every release**, so this is
|
whose public `.cer` is published next to it — the **same certificate for every release**, so this is
|
||||||
genuinely one-time and later updates need nothing. In an **admin** PowerShell:
|
genuinely one-time and later updates need nothing. In an **admin** PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Import-Certificate -FilePath .\punktfunk-host-setup.cer `
|
Import-Certificate -FilePath .\punktfunk-host-windows_<ver>.cer `
|
||||||
-CertStoreLocation Cert:\LocalMachine\TrustedPublisher
|
-CertStoreLocation Cert:\LocalMachine\TrustedPublisher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ Pick your distro to install, then your desktop to configure — the two are inde
|
|||||||
needs one of these compositor backends to create a virtual display.
|
needs one of these compositor backends to create a virtual display.
|
||||||
|
|
||||||
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
|
> **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
|
||||||
> signed installer that registers a service and bundles a virtual-display driver (whose driver-
|
> signed installer that registers a service and bundles a virtual-display driver whose driver
|
||||||
> framework needs make 22H2 the hard floor — Windows 10 is not supported). It encodes on NVIDIA
|
> framework (IddCx 1.10) makes 22H2 the hard floor — Windows 10 is not supported. It encodes on NVIDIA
|
||||||
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
|
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
|
||||||
> [Windows Host](/docs/windows-host).
|
> [Windows Host](/docs/windows-host).
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ see [Status & Progress](/docs/status).
|
|||||||
(the encoder re-targets mid-stream when bitrate is set to Automatic).
|
(the encoder re-targets mid-stream when bitrate is set to Automatic).
|
||||||
- **Surround sound** — 5.1 and 7.1 end to end: the host encodes multichannel via multistream Opus and
|
- **Surround sound** — 5.1 and 7.1 end to end: the host encodes multichannel via multistream Opus and
|
||||||
the native clients render more than two channels, with clean, synchronous stereo where the path is stereo.
|
the native clients render more than two channels, with clean, synchronous stereo where the path is stereo.
|
||||||
|
- **Clipboard sync** — bidirectional **text and images** between host and client, carried on a side
|
||||||
|
plane over the native protocol's QUIC channel.
|
||||||
|
|
||||||
## 🟡 In progress
|
## 🟡 In progress
|
||||||
|
|
||||||
@@ -90,11 +92,11 @@ see [Status & Progress](/docs/status).
|
|||||||
rate and the client presents with tearing-control/VRR instead of a fixed cadence, for tear- and
|
rate and the client presents with tearing-control/VRR instead of a fixed cadence, for tear- and
|
||||||
judder-free gaming. Builds on the client's presentation-feedback path and the per-session virtual
|
judder-free gaming. Builds on the client's presentation-feedback path and the per-session virtual
|
||||||
outputs.
|
outputs.
|
||||||
- **Desktop quality-of-life.** The essentials that make remote *work* pleasant, each a new side plane
|
- **Desktop quality-of-life.** More of the essentials that make remote *work* pleasant, each a new side
|
||||||
over the existing QUIC datagram channel: bidirectional rich **clipboard sync** (text and images),
|
plane over the existing QUIC datagram channel: **multi-monitor streaming** (present the host's several
|
||||||
**multi-monitor streaming** (present the host's several outputs as separate client windows), and
|
outputs as separate client windows) and **virtual-webcam redirection** (the client's camera shows up
|
||||||
**virtual-webcam redirection** (the client's camera shows up as a webcam on the host, so video calls
|
as a webcam on the host, so video calls run on the remote machine). *(Clipboard sync already shipped —
|
||||||
run on the remote machine).
|
see above.)*
|
||||||
|
|
||||||
## ⛔ Parked / blocked
|
## ⛔ Parked / blocked
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ Intel (QSV); the host falls back to software H.264 without one.
|
|||||||
After a reboot, from another machine on the network:
|
After a reboot, from another machine on the network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
punktfunk-probe --discover # or just look for the host in a native client / Moonlight
|
punktfunk-probe --discover # source-build dev tool (not packaged); or just open a native client / Moonlight and look for the host
|
||||||
```
|
```
|
||||||
|
|
||||||
If the host is listed, it's up. If not, check `journalctl --user -u punktfunk-host` on the host.
|
If the host is listed, it's up. If not, check `journalctl --user -u punktfunk-host` on the host.
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ host is newer than the Linux host.)
|
|||||||
- **Native resolution, no scaling.** Every session gets a virtual display at the client's
|
- **Native resolution, no scaling.** Every session gets a virtual display at the client's
|
||||||
exact resolution and refresh rate, via per-compositor backends for **KWin**,
|
exact resolution and refresh rate, via per-compositor backends for **KWin**,
|
||||||
**gamescope**, **Mutter**, and **Sway/wlroots**.
|
**gamescope**, **Mutter**, and **Sway/wlroots**.
|
||||||
- **Zero-copy GPU pipeline.** Captured frames stay on the GPU (dmabuf → CUDA → NVENC) with
|
- **Zero-copy GPU pipeline.** Captured frames stay on the GPU — dmabuf → CUDA → NVENC on NVIDIA, and
|
||||||
automatic split-encode at very high resolutions. Stable 240 fps at 5120×1440 has been
|
VAAPI or Vulkan Video on AMD/Intel — with automatic split-encode at very high resolutions. Stable
|
||||||
measured. A GPU-less software H.264 encoder exists as an explicit fallback.
|
240 fps at 5120×1440 has been measured. A GPU-less software H.264 encoder exists as an explicit fallback.
|
||||||
- **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC
|
- **HDR (10-bit), on the Windows host.** An HDR Windows desktop is captured and encoded as HEVC
|
||||||
Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now —
|
Main10 (BT.2020 PQ) to HDR-capable clients (Windows, Android). Linux hosts stream 8-bit for now —
|
||||||
HDR there is blocked upstream at the compositor.
|
HDR there is blocked upstream at the compositor.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ You need three things on the Deck:
|
|||||||
```
|
```
|
||||||
|
|
||||||
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck). Without it, the
|
(Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck). Without it, the
|
||||||
panel's **Stream** button reports `client-not-found`.)
|
panel's **Stream** button reports `flatpak-not-found`.)
|
||||||
3. **A punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
|
3. **A punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds
|
||||||
it automatically over mDNS, so nothing to configure here.
|
it automatically over mDNS, so nothing to configure here.
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu
|
|||||||
|
|
||||||
| Symptom | Fix |
|
| Symptom | Fix |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Stream** shows `client-not-found` | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
|
| **Stream** shows `flatpak-not-found` | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). |
|
||||||
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
|
| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. |
|
||||||
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
|
| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. |
|
||||||
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
|
| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. |
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ The host auto-detects a wlroots session, so you usually need nothing here. To fo
|
|||||||
these in `~/.config/punktfunk/host.env`:
|
these in `~/.config/punktfunk/host.env`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr (Hyprland has its own: PUNKTFUNK_COMPOSITOR=hyprland)
|
PUNKTFUNK_COMPOSITOR=wlroots # aliases: sway, wlr, hyprland (all the wlroots family; the exact backend is auto-detected)
|
||||||
PUNKTFUNK_INPUT_BACKEND=wlr
|
PUNKTFUNK_INPUT_BACKEND=wlr
|
||||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||||
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
# GPU zero-copy capture→encode is ON by default; auto-falls back to CPU. Set PUNKTFUNK_ZEROCOPY=0 to force CPU.
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Download the signed `punktfunk-host-setup-<ver>.exe` from the
|
|||||||
displays,
|
displays,
|
||||||
- installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360),
|
- installs the bundled **virtual gamepad drivers** (DualSense, DualShock 4, Xbox 360),
|
||||||
- registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display,
|
- registers the bundled **HDR Vulkan layer** so Vulkan games can enable HDR over the virtual display,
|
||||||
|
- optionally installs **VB-CABLE** (VB-Audio, donationware) as the virtual microphone for client
|
||||||
|
mic passthrough — a checkbox in the installer,
|
||||||
- sets up the **web management console** (see below).
|
- sets up the **web management console** (see below).
|
||||||
|
|
||||||
For an unattended install, append `/VERYSILENT`. Upgrades and uninstall go through **Add/Remove
|
For an unattended install, append `/VERYSILENT`. Upgrades and uninstall go through **Add/Remove
|
||||||
@@ -122,7 +124,7 @@ pipeline orchestration are all shared with the Linux host. The Windows host is a
|
|||||||
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
|
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
|
||||||
| **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble |
|
| **Input — gamepads** | uinput Xbox 360 + UHID DualSense/DS4 | **UMDF** virtual pads — DualSense, DualShock 4, Xbox 360 (XUSB) + rumble |
|
||||||
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
|
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
|
||||||
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic |
|
| **Virtual mic** | PipeWire `Audio/Source` | **VB-CABLE** virtual device (optional), captured via WASAPI |
|
||||||
|
|
||||||
The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The
|
The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The
|
||||||
host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
|
host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
packages = forAllSystems (system:
|
packages = forAllSystems (system:
|
||||||
let pf = packagesFor system;
|
let pf = packagesFor system;
|
||||||
in {
|
in {
|
||||||
inherit (pf) punktfunk-host punktfunk-client;
|
inherit (pf) punktfunk-host punktfunk-client punktfunk-web punktfunk-scripting punktfunk-tray;
|
||||||
default = pf.punktfunk-host;
|
default = pf.punktfunk-host;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,14 +68,26 @@
|
|||||||
type = "app";
|
type = "app";
|
||||||
program = "${pf.punktfunk-client}/bin/punktfunk-client";
|
program = "${pf.punktfunk-client}/bin/punktfunk-client";
|
||||||
};
|
};
|
||||||
|
# `nix run .#punktfunk-web` — the console (auto-wire the mgmt token / cert via env or the
|
||||||
|
# NixOS module; see packaging/nix/README.md).
|
||||||
|
punktfunk-web = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pf.punktfunk-web}/bin/punktfunk-web-server";
|
||||||
|
};
|
||||||
|
# `nix run .#punktfunk-scripting -- --list` — the plugin/script runner.
|
||||||
|
punktfunk-scripting = {
|
||||||
|
type = "app";
|
||||||
|
program = "${pf.punktfunk-scripting}/bin/punktfunk-scripting";
|
||||||
|
};
|
||||||
default = self.apps.${system}.punktfunk-host;
|
default = self.apps.${system}.punktfunk-host;
|
||||||
});
|
});
|
||||||
|
|
||||||
# `nix flake check` builds both packages.
|
# `nix flake check` builds every package (web included — needs its deps hash filled in, see
|
||||||
|
# packaging/nix/README.md).
|
||||||
checks = forAllSystems (system:
|
checks = forAllSystems (system:
|
||||||
let pf = packagesFor system;
|
let pf = packagesFor system;
|
||||||
in {
|
in {
|
||||||
inherit (pf) punktfunk-host punktfunk-client;
|
inherit (pf) punktfunk-host punktfunk-client punktfunk-web punktfunk-scripting;
|
||||||
});
|
});
|
||||||
|
|
||||||
# `nix develop` — the pinned toolchain plus every system lib the workspace links, wired so
|
# `nix develop` — the pinned toolchain plus every system lib the workspace links, wired so
|
||||||
|
|||||||
+50
-7
@@ -14,10 +14,11 @@
|
|||||||
# picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
|
# picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
|
||||||
# fallback, so it works everywhere. See README.md.
|
# fallback, so it works everywhere. See README.md.
|
||||||
pkgbase=punktfunk
|
pkgbase=punktfunk
|
||||||
# punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on
|
# punktfunk-web (the browser console) and punktfunk-scripting (the plugin/script runner) are OPT-IN:
|
||||||
# stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring
|
# both need `bun` (AUR-only as bun-bin on stock Arch/SteamOS), so a default makepkg builds only
|
||||||
# the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web
|
# host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web` / `%bcond_with
|
||||||
# (appended to pkgname + bun to makedepends below).
|
# scripting` (off by default). Set PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 to also build them (each
|
||||||
|
# appends its pkgname + bun to makedepends below).
|
||||||
pkgname=('punktfunk-host' 'punktfunk-client')
|
pkgname=('punktfunk-host' 'punktfunk-client')
|
||||||
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
|
# CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
|
||||||
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
|
# X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
|
||||||
@@ -39,11 +40,16 @@ options=('!lto' '!debug')
|
|||||||
makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git' 'vulkan-headers'
|
makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git' 'vulkan-headers'
|
||||||
'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei')
|
'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei')
|
||||||
|
|
||||||
# Opt-in punktfunk-web: only then is bun (the build tool AND the vendored runtime) required.
|
# Opt-in punktfunk-web / punktfunk-scripting: only then is bun (the build tool AND the vendored
|
||||||
|
# runtime) required. Adding bun twice is harmless (makepkg dedups makedepends).
|
||||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||||
pkgname+=('punktfunk-web')
|
pkgname+=('punktfunk-web')
|
||||||
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
|
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
|
||||||
fi
|
fi
|
||||||
|
if [ "${PF_WITH_SCRIPTING:-0}" = 1 ]; then
|
||||||
|
pkgname+=('punktfunk-scripting')
|
||||||
|
makedepends+=('bun')
|
||||||
|
fi
|
||||||
|
|
||||||
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root —
|
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root —
|
||||||
# build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to
|
# build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to
|
||||||
@@ -78,12 +84,26 @@ build() {
|
|||||||
# Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0
|
# Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0
|
||||||
# opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe.
|
# opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe.
|
||||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
|
||||||
|
# across everything in one build, so co-building the tray with the host pulls the host's
|
||||||
|
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
|
||||||
|
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
|
||||||
|
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
|
||||||
|
cargo build --release --locked -p punktfunk-tray
|
||||||
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
|
||||||
# built AND run with bun.
|
# built AND run with bun.
|
||||||
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
if [ "${PF_WITH_WEB:-0}" = 1 ]; then
|
||||||
( cd web && bun install --frozen-lockfile && bun run build )
|
( cd web && bun install --frozen-lockfile && bun run build )
|
||||||
fi
|
fi
|
||||||
|
# Plugin/script runner (opt-in): one self-contained JS via `bun build --target=bun` (effect + the
|
||||||
|
# SDK inlined; the dynamic plugin import stays a runtime import). bun is also its vendored runtime.
|
||||||
|
if [ "${PF_WITH_SCRIPTING:-0}" = 1 ]; then
|
||||||
|
( cd sdk && bun install --frozen-lockfile --ignore-scripts && \
|
||||||
|
bun build src/runner-cli.ts --target=bun --outfile="$srcdir/runner-cli.js" )
|
||||||
|
grep -q 'attempt=' "$srcdir/runner-cli.js" \
|
||||||
|
|| { echo "ERROR: runner bundle missing the dynamic plugin import — wrong build" >&2; exit 1; }
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
package_punktfunk-host() {
|
package_punktfunk-host() {
|
||||||
@@ -99,7 +119,8 @@ package_punktfunk-host() {
|
|||||||
'sway: stream a wlroots desktop (Sway VirtualDisplay backend)'
|
'sway: stream a wlroots desktop (Sway VirtualDisplay backend)'
|
||||||
'xdg-desktop-portal-kde: portal for the headless KDE session helper'
|
'xdg-desktop-portal-kde: portal for the headless KDE session helper'
|
||||||
'xdg-desktop-portal-wlr: portal for the headless Sway session helper'
|
'xdg-desktop-portal-wlr: portal for the headless Sway session helper'
|
||||||
'punktfunk-web: browser management console (device pairing + status)')
|
'punktfunk-web: browser management console (device pairing + status)'
|
||||||
|
'punktfunk-scripting: plugin/script runner for host automation (bun)')
|
||||||
install=punktfunk-host.install
|
install=punktfunk-host.install
|
||||||
# User-editable config: the headless game-mode drop-in (see below) — don't clobber local edits.
|
# User-editable config: the headless game-mode drop-in (see below) — don't clobber local edits.
|
||||||
backup=('etc/gamescope-session-plus/sessions.d/steam')
|
backup=('etc/gamescope-session-plus/sessions.d/steam')
|
||||||
@@ -227,3 +248,25 @@ package_punktfunk-web() {
|
|||||||
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-MIT"
|
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-MIT"
|
||||||
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-APACHE"
|
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-web/LICENSE-APACHE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
package_punktfunk-scripting() {
|
||||||
|
pkgdesc="punktfunk plugin/script runner (Effect SDK on bun) — supervises host automation scripts + punktfunk-plugin-* packages"
|
||||||
|
# bun is the runtime (it import()s the operator's .ts plugins), a vendored native binary, so this
|
||||||
|
# package is arch-specific (not 'any'). OPT-IN: the systemd --user unit ships disabled (the runner
|
||||||
|
# is inert until you add scripts/plugins). No nodejs/bun dependency.
|
||||||
|
local R; R="$(_repo)"
|
||||||
|
|
||||||
|
# Pre-built self-contained bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm).
|
||||||
|
install -Dm0644 "$srcdir/runner-cli.js" "$pkgdir/usr/share/punktfunk-scripting/runner-cli.js"
|
||||||
|
# Vendor the build env's bun into a private dir so it never collides with a system-wide bun.
|
||||||
|
install -Dm0755 "$(command -v bun)" "$pkgdir/usr/lib/punktfunk-scripting/bun"
|
||||||
|
install -d "$pkgdir/usr/bin"
|
||||||
|
printf '%s\n' '#!/bin/sh' 'exec /usr/lib/punktfunk-scripting/bun /usr/share/punktfunk-scripting/runner-cli.js "$@"' \
|
||||||
|
> "$pkgdir/usr/bin/punktfunk-scripting"
|
||||||
|
chmod 0755 "$pkgdir/usr/bin/punktfunk-scripting"
|
||||||
|
# systemd USER unit — installed but NOT auto-enabled (opt-in). Enable once you have automation:
|
||||||
|
# systemctl --user enable --now punktfunk-scripting
|
||||||
|
install -Dm0644 "$R/scripts/punktfunk-scripting.service" "$pkgdir/usr/lib/systemd/user/punktfunk-scripting.service"
|
||||||
|
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-scripting/LICENSE-MIT"
|
||||||
|
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-scripting/LICENSE-APACHE"
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,11 +18,24 @@ here.)
|
|||||||
|
|
||||||
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
|
Package layout mirrors the Fedora RPM (`../rpm/punktfunk.spec`): the host binary, the `/dev/uinput`
|
||||||
udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI
|
udev rule, the systemd **user** unit, headless session helpers, the example config, and the OpenAPI
|
||||||
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself (built in the Ubuntu
|
doc. Runtime `Depends` are computed by `dpkg-shlibdeps` from the binary itself. The NVIDIA driver
|
||||||
26.04 rust-ci image, so the lib soname package names match the target). The NVIDIA driver
|
|
||||||
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
|
(`libnvidia-encode` / `libEGL_nvidia` / `libcuda`) is **not** a dependency — it's installed out of
|
||||||
band, like on the RPM side.
|
band, like on the RPM side.
|
||||||
|
|
||||||
|
## Ubuntu 24.04 LTS (and why it needs a special build)
|
||||||
|
|
||||||
|
`punktfunk-host` needs **FFmpeg 8** (libavcodec62), but Ubuntu 24.04 LTS ships FFmpeg 6.1
|
||||||
|
(libavcodec60). So a host `.deb` built the obvious way — on the same Ubuntu 26.04 image as the
|
||||||
|
client (`ci/rust-ci.Dockerfile`) — declares `Depends: libavcodec62, …` and a glibc-2.41 floor that
|
||||||
|
24.04's apt can't satisfy ("the required packages are too recent"). To fix that, the host `.deb` is
|
||||||
|
instead built on an **Ubuntu 24.04 image** (`ci/rust-ci-noble.Dockerfile`) that carries a from-source
|
||||||
|
FFmpeg 8, and that FFmpeg is **bundled into the package** (`build-deb.sh BUNDLE_FFMPEG=1` → the
|
||||||
|
libav* land in `/usr/lib/punktfunk-host`, the binary's rpath points there, and the libav* sonames are
|
||||||
|
dropped from `Depends`). The result is **one** host `.deb` that installs on **Ubuntu 24.04 LTS through
|
||||||
|
26.04** (glibc floor 2.39; no distro-FFmpeg dependency). The client/web/scripting `.deb`s still build
|
||||||
|
on 26.04 (the native client needs SDL3 / GTK4 ≥ 4.20, absent on 24.04) — install the client on the box
|
||||||
|
you stream *to*, which is independent of the host's distro.
|
||||||
|
|
||||||
## Install on a host (one-time)
|
## Install on a host (one-time)
|
||||||
|
|
||||||
The registry is public, so no apt auth is needed — just trust the repo's signing key:
|
The registry is public, so no apt auth is needed — just trust the repo's signing key:
|
||||||
@@ -144,5 +157,17 @@ VERSION=0.0.1 bash packaging/debian/build-deb.sh # -> dist/punktfunk-host_0.0.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing.
|
Needs `dpkg-dev` (`dpkg-shlibdeps`, `dpkg-deb`). It builds the release binary first if missing.
|
||||||
Build it in the rust-ci image (or on an Ubuntu 26.04 box) so the resolved `Depends` match the
|
Building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
|
||||||
hosts; building on a GPU box is fine — the NVIDIA driver lib is filtered out either way.
|
|
||||||
|
That plain invocation hard-depends on the build box's system FFmpeg, so it only installs on a box
|
||||||
|
with the same libav* soname. For the **universal** package CI ships (installs on 24.04 LTS → 26.04),
|
||||||
|
build it in the noble image with FFmpeg bundled:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f ci/rust-ci-noble.Dockerfile -t pf-noble ci
|
||||||
|
docker run --rm -v "$PWD:/src" -w /src pf-noble \
|
||||||
|
bash -lc 'VERSION=0.0.1 BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
`BUNDLE_FFMPEG=1` needs `patchelf` and an FFmpeg install at `FFMPEG_PREFIX` (default `/opt/ffmpeg`,
|
||||||
|
which the noble image provides).
|
||||||
|
|||||||
@@ -13,13 +13,23 @@
|
|||||||
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
|
# FFI): on a GPU-less builder it resolves to no package, and we must never hard-depend on a
|
||||||
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
|
# specific libnvidia-compute-<ver> anyway — NVENC/EGL come from the driver, out of band.
|
||||||
#
|
#
|
||||||
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] bash packaging/debian/build-deb.sh
|
# BUNDLE_FFMPEG=1 (Ubuntu 24.04 LTS builds, ci/rust-ci-noble.Dockerfile): instead of hard-depending
|
||||||
|
# on the distro's libav* — which don't exist on 24.04 (it ships FFmpeg 6.1 / libavcodec60, the host
|
||||||
|
# needs 8 / libavcodec62) — copy a from-source FFmpeg into /usr/lib/punktfunk-host, repoint the
|
||||||
|
# binary's rpath there, and drop the libav*/libsw*/libpostproc sonames from the auto Depends. Set
|
||||||
|
# FFMPEG_PREFIX to that FFmpeg's install prefix (default /opt/ffmpeg, as the noble image sets it).
|
||||||
|
# See packaging/debian/README.md → "Ubuntu 24.04 LTS".
|
||||||
|
#
|
||||||
|
# Usage: VERSION=0.0.1~ci42.gdeadbee [ARCH=amd64] [BUNDLE_FFMPEG=1] bash packaging/debian/build-deb.sh
|
||||||
# Output: dist/punktfunk-host_<version>_<arch>.deb
|
# Output: dist/punktfunk-host_<version>_<arch>.deb
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||||
ARCH="${ARCH:-amd64}"
|
ARCH="${ARCH:-amd64}"
|
||||||
PKG="punktfunk-host"
|
PKG="punktfunk-host"
|
||||||
|
BUNDLE_FFMPEG="${BUNDLE_FFMPEG:-0}"
|
||||||
|
FFMPEG_PREFIX="${FFMPEG_PREFIX:-/opt/ffmpeg}"
|
||||||
|
LIBDIR_REL="usr/lib/$PKG" # bundled FFmpeg lands here: /usr/lib/punktfunk-host
|
||||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
cd "$ROOTDIR"
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
@@ -119,8 +129,41 @@ printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <n
|
|||||||
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
|
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
|
||||||
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
|
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
|
||||||
|
|
||||||
|
# --- bundled FFmpeg (Ubuntu 24.04 LTS builds) --------------------------------
|
||||||
|
# Copy the from-source libav*/libsw*/libpostproc .so's into /usr/lib/punktfunk-host and repoint the
|
||||||
|
# binary at them, so the package carries FFmpeg 8 instead of depending on a distro libavcodec62 that
|
||||||
|
# 24.04 doesn't have. BUNDLED_LIBS is fed to dpkg-shlibdeps below so the libs' OWN external deps
|
||||||
|
# (libva2, libdrm2, …, all present on 24.04) still become Depends.
|
||||||
|
BUNDLED_LIBS=""
|
||||||
|
if [ "$BUNDLE_FFMPEG" = "1" ]; then
|
||||||
|
command -v patchelf >/dev/null || { echo "BUNDLE_FFMPEG=1 needs patchelf" >&2; exit 1; }
|
||||||
|
[ -d "$FFMPEG_PREFIX/lib" ] || { echo "FFMPEG_PREFIX=$FFMPEG_PREFIX has no lib/ — build FFmpeg first" >&2; exit 1; }
|
||||||
|
DEST="$STAGE/$LIBDIR_REL"
|
||||||
|
install -d "$DEST"
|
||||||
|
# cp -a preserves the SONAME symlink chain (libavcodec.so -> .so.62 -> .so.62.x.x); the loader
|
||||||
|
# resolves the binary's DT_NEEDED (libavcodec.so.62) to the middle link.
|
||||||
|
shopt -s nullglob
|
||||||
|
for so in "$FFMPEG_PREFIX"/lib/lib{avcodec,avformat,avutil,avfilter,avdevice,swscale,swresample,postproc}.so*; do
|
||||||
|
cp -a "$so" "$DEST/"
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
|
ls "$DEST"/libavcodec.so.* >/dev/null 2>&1 || { echo "no libav* found under $FFMPEG_PREFIX/lib" >&2; exit 1; }
|
||||||
|
# Each bundled lib finds its siblings (libavcodec needs libavutil) via its own $ORIGIN RUNPATH;
|
||||||
|
# patch only the real versioned files, not the symlinks. The executable then finds the top-level
|
||||||
|
# libs via ../lib/$PKG, written as DT_RPATH (--force-rpath) so it's also searched transitively —
|
||||||
|
# belt-and-suspenders against DT_RUNPATH's non-transitivity.
|
||||||
|
for so in "$DEST"/*.so.*; do
|
||||||
|
[ -L "$so" ] && continue
|
||||||
|
patchelf --set-rpath '$ORIGIN' "$so"
|
||||||
|
done
|
||||||
|
patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$PKG" "$STAGE/usr/bin/$PKG"
|
||||||
|
BUNDLED_LIBS="$(printf '%s ' "$DEST"/*.so.*)"
|
||||||
|
echo "==> bundled FFmpeg from $FFMPEG_PREFIX into /$LIBDIR_REL"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- dependencies ------------------------------------------------------------
|
# --- dependencies ------------------------------------------------------------
|
||||||
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header).
|
# Auto: the binary's directly-linked shared libs (libcuda ignored, see header). In bundle mode the
|
||||||
|
# bundled .so's are appended so their external deps (libva2/libdrm2/…) are captured too.
|
||||||
SHLIB_TMP="$(mktemp -d)"
|
SHLIB_TMP="$(mktemp -d)"
|
||||||
mkdir -p "$SHLIB_TMP/debian"
|
mkdir -p "$SHLIB_TMP/debian"
|
||||||
cat > "$SHLIB_TMP/debian/control" <<EOF
|
cat > "$SHLIB_TMP/debian/control" <<EOF
|
||||||
@@ -130,8 +173,16 @@ Package: $PKG
|
|||||||
Architecture: any
|
Architecture: any
|
||||||
Depends: \${shlibs:Depends}
|
Depends: \${shlibs:Depends}
|
||||||
EOF
|
EOF
|
||||||
SHDEPS_RAW="$(cd "$SHLIB_TMP" && dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" 2>/dev/null \
|
# In bundle mode the libav* live in FFMPEG_PREFIX/lib — not a standard loader path, and the
|
||||||
| sed -n 's/^shlibs:Depends=//p')"
|
# target/release binary carries no rpath (only the staged copy does) — so dpkg-shlibdeps can't
|
||||||
|
# locate libavcodec.so.62 and exits 2. Point it there via LD_LIBRARY_PATH. Stderr is captured so a
|
||||||
|
# future resolution failure is visible instead of swallowed.
|
||||||
|
SHDEPS_RAW="$(
|
||||||
|
cd "$SHLIB_TMP"
|
||||||
|
if [ "$BUNDLE_FFMPEG" = "1" ]; then export LD_LIBRARY_PATH="$FFMPEG_PREFIX/lib"; fi
|
||||||
|
dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" $BUNDLED_LIBS 2>"$SHLIB_TMP/err" \
|
||||||
|
| sed -n 's/^shlibs:Depends=//p'
|
||||||
|
)" || { echo "dpkg-shlibdeps failed (exit $?):" >&2; sed 's/^/ /' "$SHLIB_TMP/err" >&2; rm -rf "$SHLIB_TMP"; exit 1; }
|
||||||
rm -rf "$SHLIB_TMP"
|
rm -rf "$SHLIB_TMP"
|
||||||
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
|
[ -n "$SHDEPS_RAW" ] || { echo "dpkg-shlibdeps produced no deps — is dpkg-dev installed?" >&2; exit 1; }
|
||||||
|
|
||||||
@@ -139,8 +190,12 @@ rm -rf "$SHLIB_TMP"
|
|||||||
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
|
# GPU-less builder (stub, no owning package), but on a box WITH the driver shlibdeps resolves
|
||||||
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
|
# libcuda.so.1 -> libnvidia-compute-<ver> and would pin that exact driver build. NVENC/EGL are
|
||||||
# provided by whatever driver the host runs, so this must never be a package dependency.
|
# provided by whatever driver the host runs, so this must never be a package dependency.
|
||||||
|
# In bundle mode also drop the FFmpeg sonames: they're shipped inside the package (/usr/lib/$PKG),
|
||||||
|
# not pulled from apt, so a `Depends: libavcodec62` would wrongly re-block install on 24.04.
|
||||||
|
FILTER='^(libnvidia-compute|libcuda)'
|
||||||
|
[ "$BUNDLE_FFMPEG" = "1" ] && FILTER='^(libnvidia-compute|libcuda|libav|libsw|libpostproc)'
|
||||||
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
|
SHDEPS="$(printf '%s' "$SHDEPS_RAW" | tr ',' '\n' | sed 's/^ *//; s/ *$//' \
|
||||||
| grep -ivE '^(libnvidia-compute|libcuda)' | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
|
| grep -ivE "$FILTER" | awk 'NF' | paste -sd ',' - | sed 's/,/, /g')"
|
||||||
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
|
[ -n "$SHDEPS" ] || { echo "no deps left after filtering — unexpected" >&2; exit 1; }
|
||||||
|
|
||||||
# Manual additions shlibdeps can't see:
|
# Manual additions shlibdeps can't see:
|
||||||
@@ -156,7 +211,9 @@ DEPENDS="$SHDEPS, libei1, pipewire, wireplumber"
|
|||||||
# punktfunk-web = the management web console (pairing + status) every user needs — a separate
|
# punktfunk-web = the management web console (pairing + status) every user needs — a separate
|
||||||
# Architecture:all .deb; Recommends so `apt install punktfunk-host` pulls it by default, while a
|
# Architecture:all .deb; Recommends so `apt install punktfunk-host` pulls it by default, while a
|
||||||
# headless/encoding-only box can opt out with --no-install-recommends.
|
# headless/encoding-only box can opt out with --no-install-recommends.
|
||||||
RECOMMENDS="ffmpeg, gamescope, pipewire-pulse, mesa-va-drivers, intel-media-va-driver, punktfunk-web"
|
# punktfunk-scripting = the plugin/script runner (host automation on bun). Recommends so it's pulled
|
||||||
|
# by default; its systemd --user unit ships disabled (inert until you add scripts/plugins).
|
||||||
|
RECOMMENDS="ffmpeg, gamescope, pipewire-pulse, mesa-va-drivers, intel-media-va-driver, punktfunk-web, punktfunk-scripting"
|
||||||
SUGGESTS="kwin-wayland, mutter"
|
SUGGESTS="kwin-wayland, mutter"
|
||||||
|
|
||||||
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
|
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
|
||||||
|
|||||||
Executable
+140
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the punktfunk-scripting .deb — the plugin/script runner (the SDK's `punktfunk-scripting`,
|
||||||
|
# built on Effect, run on bun).
|
||||||
|
#
|
||||||
|
# Runtime is BUN: the runner `import()`s the operator's `.ts` plugin/script files directly, which
|
||||||
|
# only bun can do. Like the web console, we VENDOR a bun binary into the package (bun isn't in apt),
|
||||||
|
# which makes the package per-arch (amd64/arm64), NOT `all`. Unlike the console it is NOT a Nitro
|
||||||
|
# bundle: we `bun build` the runner CLI into ONE self-contained JS (effect + the SDK inlined; the
|
||||||
|
# dynamic plugin import stays a runtime import), so there is no node_modules to ship. The host's
|
||||||
|
# punktfunk-host .deb Recommends this so a default `apt install punktfunk-host` pulls the runner too;
|
||||||
|
# its systemd --user unit is installed but NOT auto-enabled (the runner is inert until you add
|
||||||
|
# scripts/plugins — enable it with `systemctl --user enable --now punktfunk-scripting`).
|
||||||
|
#
|
||||||
|
# Usage: VERSION=0.0.1~ci42.gdeadbee [DEB_ARCH=amd64] [BUN_BIN=/path/to/bun] bash packaging/debian/build-scripting-deb.sh
|
||||||
|
# Output: dist/punktfunk-scripting_<version>_<arch>.deb
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
|
||||||
|
PKG="punktfunk-scripting"
|
||||||
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
|
# Per-arch: vendor bun for the target Debian arch. Map deb arch → bun's release arch tag.
|
||||||
|
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
|
||||||
|
BUN_VERSION="${BUN_VERSION:-1.3.14}" # pinned bun build vendored into the package (matches build-web-deb.sh)
|
||||||
|
case "$DEB_ARCH" in
|
||||||
|
amd64) BUN_ARCH=x64 ;;
|
||||||
|
arm64) BUN_ARCH=aarch64 ;;
|
||||||
|
*) echo "ERROR: unsupported DEB_ARCH=$DEB_ARCH (want amd64 or arm64)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
SHAREDIR="$STAGE/usr/share/$PKG"
|
||||||
|
DOCDIR="$STAGE/usr/share/doc/$PKG"
|
||||||
|
LIBDIR="$STAGE/usr/lib/$PKG"
|
||||||
|
|
||||||
|
# --- build the runner bundle -------------------------------------------------
|
||||||
|
# One self-contained JS: `bun build --target=bun` inlines effect + the @punktfunk/host SDK; the
|
||||||
|
# runner's dynamic `import()` of the operator's plugin files is left as a runtime import (bun keeps
|
||||||
|
# unresolvable dynamic specifiers external). `--ignore-scripts` on install: nothing needs a
|
||||||
|
# postinstall, and we skip the `prepare` codegen (it wants ../api/openapi.json — not needed here).
|
||||||
|
mkdir -p "$SHAREDIR"
|
||||||
|
(
|
||||||
|
cd sdk
|
||||||
|
bun install --frozen-lockfile --ignore-scripts
|
||||||
|
bun build src/runner-cli.ts --target=bun --outfile="$SHAREDIR/runner-cli.js"
|
||||||
|
)
|
||||||
|
grep -q 'attempt=' "$SHAREDIR/runner-cli.js" \
|
||||||
|
|| { echo "ERROR: runner bundle missing the dynamic plugin import — wrong build" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- vendor the bun runtime --------------------------------------------------
|
||||||
|
# Honor a pre-fetched bun (CI may cache it) via BUN_BIN; else download the pinned release.
|
||||||
|
mkdir -p "$LIBDIR"
|
||||||
|
if [ -n "${BUN_BIN:-}" ]; then
|
||||||
|
echo "==> vendoring bun from BUN_BIN=$BUN_BIN"
|
||||||
|
install -m0755 "$BUN_BIN" "$LIBDIR/bun"
|
||||||
|
else
|
||||||
|
url="https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip"
|
||||||
|
echo "==> downloading bun $BUN_VERSION ($BUN_ARCH) from $url"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL "$url" -o "$tmp/bun.zip"
|
||||||
|
unzip -q "$tmp/bun.zip" -d "$tmp"
|
||||||
|
install -m0755 "$tmp/bun-linux-${BUN_ARCH}/bun" "$LIBDIR/bun"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
fi
|
||||||
|
"$LIBDIR/bun" --version
|
||||||
|
|
||||||
|
# --- file layout -------------------------------------------------------------
|
||||||
|
# Stable PATH-independent launcher (the systemd unit's ExecStart) — runs the bundle on vendored bun.
|
||||||
|
install -d "$STAGE/usr/bin"
|
||||||
|
cat > "$STAGE/usr/bin/punktfunk-scripting" <<'WRAP'
|
||||||
|
#!/bin/sh
|
||||||
|
# The runner runs on the vendored bun (it import()s the operator's .ts plugins); bun lives privately
|
||||||
|
# under /usr/lib/punktfunk-scripting so it never collides with a system-wide bun on PATH.
|
||||||
|
exec /usr/lib/punktfunk-scripting/bun /usr/share/punktfunk-scripting/runner-cli.js "$@"
|
||||||
|
WRAP
|
||||||
|
chmod 0755 "$STAGE/usr/bin/punktfunk-scripting"
|
||||||
|
install -Dm0644 scripts/punktfunk-scripting.service "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service"
|
||||||
|
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
|
||||||
|
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
|
||||||
|
install -Dm0644 sdk/README.md "$DOCDIR/README.md"
|
||||||
|
|
||||||
|
cat > "$DOCDIR/copyright" <<EOF
|
||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: punktfunk
|
||||||
|
Source: https://git.unom.io/unom/punktfunk
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: punktfunk contributors
|
||||||
|
License: MIT or Apache-2.0
|
||||||
|
Dual-licensed. Full texts in /usr/share/doc/$PKG/LICENSE-MIT and
|
||||||
|
/usr/share/doc/$PKG/LICENSE-APACHE.
|
||||||
|
EOF
|
||||||
|
printf '%s (%s) stable; urgency=medium\n\n * Automated build %s.\n\n -- unom <noreply@anthropic.com> %s\n' \
|
||||||
|
"$PKG" "$VERSION" "$VERSION" "$(date -uR 2>/dev/null || echo 'Thu, 01 Jan 1970 00:00:00 +0000')" \
|
||||||
|
| gzip -9n > "$DOCDIR/changelog.Debian.gz"
|
||||||
|
|
||||||
|
INSTALLED_KB="$(du -k -s "$STAGE" | cut -f1)"
|
||||||
|
|
||||||
|
install -d "$STAGE/DEBIAN"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
|
Package: $PKG
|
||||||
|
Version: $VERSION
|
||||||
|
Architecture: $DEB_ARCH
|
||||||
|
Maintainer: unom <noreply@anthropic.com>
|
||||||
|
Installed-Size: $INSTALLED_KB
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Homepage: https://git.unom.io/unom/punktfunk
|
||||||
|
Description: punktfunk plugin/script runner (Effect SDK on bun)
|
||||||
|
Runs a punktfunk host's automation: loose scripts in ~/.config/punktfunk/scripts and installed
|
||||||
|
punktfunk-plugin-* packages under ~/.config/punktfunk/plugins, each supervised (Effect fibers with
|
||||||
|
capped-jittered restart; SIGTERM shuts the whole tree down structurally so plugin finalizers run).
|
||||||
|
Bundles its own bun runtime (no system nodejs/bun dependency).
|
||||||
|
.
|
||||||
|
OPT-IN: the systemd --user unit is installed but not auto-enabled (the runner is inert until you add
|
||||||
|
scripts or plugins). A plugin auto-wires to the host's mgmt token + identity cert on the same box —
|
||||||
|
no env editing. Enable it with: systemctl --user enable --now punktfunk-scripting
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$STAGE/DEBIAN/postinst" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "configure" ]; then
|
||||||
|
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
|
||||||
|
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
|
||||||
|
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
|
||||||
|
echo "then enable the runner for your user:"
|
||||||
|
echo " systemctl --user enable --now punktfunk-scripting"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
chmod 0755 "$STAGE/DEBIAN/postinst"
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
|
||||||
|
echo "built $OUT"
|
||||||
|
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true
|
||||||
+54
-1
@@ -16,8 +16,10 @@ and the native Linux **client**, a **NixOS module** that wires up everything the
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `packages.x86_64-linux.punktfunk-host` | `punktfunk-host` + `punktfunk-tray` (built with `nvenc` + `vulkan-encode`, like CI) |
|
| `packages.x86_64-linux.punktfunk-host` | `punktfunk-host` + `punktfunk-tray` (built with `nvenc` + `vulkan-encode`, like CI) |
|
||||||
| `packages.x86_64-linux.punktfunk-client` | `punktfunk-client` (GTK4 shell) + `punktfunk-session` (Vulkan streamer, without the Skia OSD — see caveats) |
|
| `packages.x86_64-linux.punktfunk-client` | `punktfunk-client` (GTK4 shell) + `punktfunk-session` (Vulkan streamer, without the Skia OSD — see caveats) |
|
||||||
|
| `packages.x86_64-linux.punktfunk-web` | the management web console (bun-built Nitro SSR bundle; SPAKE2 pairing + host status) |
|
||||||
|
| `packages.x86_64-linux.punktfunk-scripting` | the plugin/script runner (bun-bundled Effect SDK; supervises host automation) |
|
||||||
| `packages.x86_64-linux.default` | = `punktfunk-host` |
|
| `packages.x86_64-linux.default` | = `punktfunk-host` |
|
||||||
| `nixosModules.default` | `services.punktfunk.host` / `services.punktfunk.client` |
|
| `nixosModules.default` | `services.punktfunk.host` / `.client` / `.web` / `.scripting` |
|
||||||
| `devShells.x86_64-linux.default` | pinned Rust (from `rust-toolchain.toml`) + all build deps |
|
| `devShells.x86_64-linux.default` | pinned Rust (from `rust-toolchain.toml`) + all build deps |
|
||||||
| `apps` / `checks` / `formatter` | `nix run`, `nix flake check`, `nix fmt` |
|
| `apps` / `checks` / `formatter` | `nix run`, `nix flake check`, `nix fmt` |
|
||||||
|
|
||||||
@@ -105,6 +107,39 @@ systemctl --user enable --now punktfunk-host
|
|||||||
|
|
||||||
`services.punktfunk.client`: `enable`, `openFirewall` (UDP 5353), `package`.
|
`services.punktfunk.client`: `enable`, `openFirewall` (UDP 5353), `package`.
|
||||||
|
|
||||||
|
`services.punktfunk.web` (the management console — **on by default whenever the host is enabled**,
|
||||||
|
mirroring the RPM's `Recommends: punktfunk-web`):
|
||||||
|
|
||||||
|
| Option | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enable` | `host.enable` | Run the console as a `systemd --user` service on **TCP 47992 (HTTPS)**. Set `false` for a console-less host. |
|
||||||
|
| `openFirewall` | `host.openFirewall` | Open TCP 47992 so other devices on the LAN can reach it. |
|
||||||
|
| `autoStart` | `host.autoStart` | Add the console user service to `default.target` (appliance mode). |
|
||||||
|
| `package` | flake's | Override the package. |
|
||||||
|
|
||||||
|
The console is **auto-wired** to the host on the same box: it reads the host's per-user
|
||||||
|
`~/.config/punktfunk/{mgmt-token,cert.pem,key.pem}` (written by `serve`), serves HTTPS with the
|
||||||
|
host's own identity cert, and proxies the loopback mgmt API with the bearer token injected
|
||||||
|
server-side (never sent to the browser). A login password is generated on first start — read it
|
||||||
|
with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-password`). Then open
|
||||||
|
`https://<host-ip>:47992` and trust the self-signed host cert once. Enable it (with the host) via
|
||||||
|
`systemctl --user enable --now punktfunk-web`.
|
||||||
|
|
||||||
|
`services.punktfunk.scripting` (the plugin/script runner — installed with the host, but **opt-in to
|
||||||
|
run**):
|
||||||
|
|
||||||
|
| Option | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enable` | `host.enable` | Install the runner + define its `systemd --user` unit `punktfunk-scripting`. |
|
||||||
|
| `autoStart` | `false` | Add the unit to `default.target`. Off even on an auto-start host — running operator scripts/plugins is a deliberate opt-in. |
|
||||||
|
| `package` | flake's | Override the package. |
|
||||||
|
|
||||||
|
The runner discovers loose scripts under `~/.config/punktfunk/scripts` and installed
|
||||||
|
`punktfunk-plugin-*` packages under `~/.config/punktfunk/plugins`, and supervises each as an Effect
|
||||||
|
fiber (SIGTERM shuts the tree down structurally so plugin finalizers run). A plugin auto-wires to
|
||||||
|
the host's mgmt token + identity cert. It's inert until you add automation, so the unit ships
|
||||||
|
un-started; turn it on with `systemctl --user enable --now punktfunk-scripting`.
|
||||||
|
|
||||||
### What the host module configures for you
|
### What the host module configures for you
|
||||||
|
|
||||||
Everything the RPM's `%install` + `%post` do, declaratively:
|
Everything the RPM's `%install` + `%post` do, declaratively:
|
||||||
@@ -175,6 +210,24 @@ The shell exports `PF_FFVK_VULKAN_INCLUDE` (Vulkan headers for pf-ffvk bindgen)
|
|||||||
- **First build compiles from scratch** (no split dep cache — pyrowave-sys builds a CMake tree in
|
- **First build compiles from scratch** (no split dep cache — pyrowave-sys builds a CMake tree in
|
||||||
its build.rs that a crane "dummy" source would drop) and has no public binary cache, so expect a
|
its build.rs that a crane "dummy" source would drop) and has no public binary cache, so expect a
|
||||||
long initial build. `nix develop` gives incremental rebuilds.
|
long initial build. `nix develop` gives incremental rebuilds.
|
||||||
|
- **The status tray is built in its own derivation, on purpose.** `punktfunk-tray` uses `ksni`'s
|
||||||
|
`async-io` zbus executor with no tokio runtime (by design — see `crates/punktfunk-tray/Cargo.toml`).
|
||||||
|
Cargo unifies features across everything in one `cargo build`, so co-building the tray with the
|
||||||
|
host would pull the host's `ashpd → zbus/tokio` onto the tray's shared `zbus`, and the tray then
|
||||||
|
panics at startup (`there is no reactor running, must be called from the context of a Tokio 1.x
|
||||||
|
runtime`). Building it as a separate `-p punktfunk-tray` invocation keeps its `zbus` on async-io;
|
||||||
|
the host package copies the resulting binary into its `$out`. (The deb/rpm/arch builds co-build the
|
||||||
|
two in one `cargo build`, so they share this latent crash on Linux — a separate fix.)
|
||||||
|
- **The bun packages (`punktfunk-web`, `punktfunk-scripting`) — their `bun install` deps hashes.**
|
||||||
|
Both build their `node_modules` in a *fixed-output derivation* (`bun install` needs the network +
|
||||||
|
the read-public `@unom` npm registry). Each `outputHash` (in `packaging/nix/packages.nix`) is
|
||||||
|
pinned to a resolved dependency set and **must be refreshed when its lockfile changes** —
|
||||||
|
`web/bun.lock` for the console, `sdk/bun.lock` for the runner: set that `outputHash = lib.fakeHash`,
|
||||||
|
run `nix build .#punktfunk-web` (or `.#punktfunk-scripting`), and copy the `got: sha256-…` value
|
||||||
|
Nix prints back into the field. Everything downstream is offline (the console's codegen + vite
|
||||||
|
build; the runner's `bun build --target=bun` bundle), so only the deps FODs ever need network.
|
||||||
|
Both launchers exec `pkgs.bun` from the store — unlike the deb/rpm, which vendor a bun binary
|
||||||
|
because apt/dnf have none.
|
||||||
- **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay). It is
|
- **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay). It is
|
||||||
generated on first eval and checked in.
|
generated on first eval and checked in.
|
||||||
- **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its
|
- **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in
|
# NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in
|
||||||
# their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh):
|
# their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh):
|
||||||
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
|
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
|
||||||
# UDP socket-buffer sysctls, the firewall openers, and the `input`-group membership for virtual
|
# UDP socket-buffer sysctls, the firewall openers, the `input`-group membership for virtual
|
||||||
# gamepads.
|
# gamepads, the management web console (`services.punktfunk.web`, on by default with the host — the
|
||||||
|
# RPM/deb Recommends), and the opt-in plugin/script runner (`services.punktfunk.scripting`).
|
||||||
#
|
#
|
||||||
# Usage (flake):
|
# Usage (flake):
|
||||||
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
|
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
|
||||||
@@ -149,6 +150,84 @@ in
|
|||||||
description = "Open UDP 5353 (mDNS) so the client can auto-discover hosts on the LAN.";
|
description = "Open UDP 5353 (mDNS) so the client can auto-discover hosts on the LAN.";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The management web console (SPAKE2 PIN pairing + host status) — the browser UI every client
|
||||||
|
# needs. Ships by DEFAULT alongside the host (mirrors the RPM's `Recommends: punktfunk-web` and
|
||||||
|
# the .deb the host package pulls in), auto-wired to the host's mgmt token + identity cert.
|
||||||
|
web = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = cfg.host.enable;
|
||||||
|
defaultText = literalExpression "config.services.punktfunk.host.enable";
|
||||||
|
description = ''
|
||||||
|
Run the management web console as a `systemd --user` service on TCP 47992 (HTTPS). Enabled
|
||||||
|
by default whenever the host is enabled — set to `false` for a console-less host. It
|
||||||
|
auto-wires to `~/.config/punktfunk/{mgmt-token,cert.pem,key.pem}` (written by the host's
|
||||||
|
`serve`) and generates a login password on first start.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = self.packages.${system}.punktfunk-web;
|
||||||
|
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-web";
|
||||||
|
description = "The punktfunk-web package (the bun-built Nitro SSR console bundle).";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = cfg.host.openFirewall;
|
||||||
|
defaultText = literalExpression "config.services.punktfunk.host.openFirewall";
|
||||||
|
description = "Open TCP 47992 so the console is reachable from other devices on the LAN.";
|
||||||
|
};
|
||||||
|
|
||||||
|
autoStart = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = cfg.host.autoStart;
|
||||||
|
defaultText = literalExpression "config.services.punktfunk.host.autoStart";
|
||||||
|
description = ''
|
||||||
|
Start the console automatically in every user's graphical session (adds it to the user
|
||||||
|
`default.target`). Follows the host's `autoStart` by default — for a login-less appliance,
|
||||||
|
enable lingering for the user as well.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# The plugin/script runner — host automation on bun. Ships with the host (the RPM/deb Recommends
|
||||||
|
# it), but running it is OPT-IN: the `systemd --user` unit is defined yet NOT added to
|
||||||
|
# `default.target`, because the runner is inert until you add scripts/plugins. Turn it on with
|
||||||
|
# `systemctl --user enable --now punktfunk-scripting`.
|
||||||
|
scripting = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = cfg.host.enable;
|
||||||
|
defaultText = literalExpression "config.services.punktfunk.host.enable";
|
||||||
|
description = ''
|
||||||
|
Install the plugin/script runner and define its `systemd --user` unit
|
||||||
|
(`punktfunk-scripting`). Enabled by default whenever the host is — but the unit is not
|
||||||
|
auto-started (see `autoStart`), since the runner does nothing until you add scripts to
|
||||||
|
`~/.config/punktfunk/scripts` or install `punktfunk-plugin-*` packages under
|
||||||
|
`~/.config/punktfunk/plugins`. A plugin auto-wires to the host's mgmt token + identity cert.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = self.packages.${system}.punktfunk-scripting;
|
||||||
|
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-scripting";
|
||||||
|
description = "The punktfunk-scripting package (the bun-bundled Effect SDK runner).";
|
||||||
|
};
|
||||||
|
|
||||||
|
autoStart = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Start the runner automatically in every user's graphical session (adds it to the user
|
||||||
|
`default.target`). Off by default even when the host auto-starts — running arbitrary
|
||||||
|
operator scripts/plugins is a deliberate opt-in; enable it once you have automation to run.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = mkMerge [
|
config = mkMerge [
|
||||||
@@ -226,5 +305,91 @@ in
|
|||||||
allowedUDPPorts = [ 5353 ];
|
allowedUDPPorts = [ 5353 ];
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# --- web console ---------------------------------------------------------------------------
|
||||||
|
# The declarative equivalent of the punktfunk-web .deb / RPM subpackage: the two systemd --user
|
||||||
|
# units (the console + its first-run password generator) plus the firewall opener, all auto-wired
|
||||||
|
# to the host's per-user mgmt token + identity cert (no env editing on a packaged install).
|
||||||
|
(mkIf cfg.web.enable {
|
||||||
|
environment.systemPackages = [ cfg.web.package ];
|
||||||
|
|
||||||
|
networking.firewall = mkIf cfg.web.openFirewall {
|
||||||
|
allowedTCPPorts = [ 47992 ]; # console HTTPS (packaging/linux/punktfunk-web.xml)
|
||||||
|
};
|
||||||
|
|
||||||
|
# First-run setup: generate the console login password once, in the user's config dir, and
|
||||||
|
# surface it to the --user journal. Self-gates via ConditionPathExists (mirrors
|
||||||
|
# scripts/punktfunk-web-init.service).
|
||||||
|
systemd.user.services.punktfunk-web-init = {
|
||||||
|
description = "punktfunk web console first-run setup (login password)";
|
||||||
|
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||||
|
unitConfig.ConditionPathExists = "!%h/.config/punktfunk/web-password";
|
||||||
|
path = [ pkgs.coreutils ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
ExecStart = "${cfg.web.package}/share/punktfunk-web/web-init.sh";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# The console itself: Nitro SSR on bun, HTTPS on 47992 with the host's identity cert, proxying
|
||||||
|
# the host's loopback mgmt API with the bearer token injected server-side. mgmt-token is
|
||||||
|
# REQUIRED (the host's `serve` writes it) — if absent the unit fails and Restart retries until
|
||||||
|
# the host has created it; web-password is optional ('-'). Mirrors scripts/punktfunk-web.service.
|
||||||
|
systemd.user.services.punktfunk-web = {
|
||||||
|
description = "punktfunk management web console";
|
||||||
|
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||||
|
after = [ "punktfunk-web-init.service" "punktfunk-host.service" ];
|
||||||
|
wants = [ "punktfunk-web-init.service" ];
|
||||||
|
wantedBy = optional cfg.web.autoStart "default.target";
|
||||||
|
environment = {
|
||||||
|
PUNKTFUNK_MGMT_URL = "https://127.0.0.1:47990";
|
||||||
|
PORT = "47992";
|
||||||
|
HOST = "0.0.0.0";
|
||||||
|
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
|
||||||
|
# mark the session cookie Secure. The host's `serve` writes these PEMs.
|
||||||
|
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
|
||||||
|
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
|
||||||
|
PUNKTFUNK_UI_SECURE = "1";
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
EnvironmentFile = [
|
||||||
|
"%h/.config/punktfunk/mgmt-token"
|
||||||
|
"-%h/.config/punktfunk/web-password"
|
||||||
|
];
|
||||||
|
ExecStart = "${cfg.web.package}/bin/punktfunk-web-server";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 2;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- plugin/script runner ------------------------------------------------------------------
|
||||||
|
# Installs the runner + defines its opt-in `systemd --user` unit (mirrors the deb/rpm
|
||||||
|
# punktfunk-scripting subpackage). NOT auto-started unless `scripting.autoStart` is set.
|
||||||
|
(mkIf cfg.scripting.enable {
|
||||||
|
environment.systemPackages = [ cfg.scripting.package ];
|
||||||
|
|
||||||
|
systemd.user.services.punktfunk-scripting = {
|
||||||
|
description = "punktfunk plugin/script runner";
|
||||||
|
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
||||||
|
# Plugins talk to the host's loopback mgmt API; order after it (soft — the runner backs off
|
||||||
|
# and retries per unit, so this is ordering only, not a hard requirement).
|
||||||
|
after = [ "punktfunk-host.service" ];
|
||||||
|
wantedBy = optional cfg.scripting.autoStart "default.target";
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
ExecStart = "${cfg.scripting.package}/bin/punktfunk-scripting";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 2;
|
||||||
|
# Deliver SIGTERM to the runner (it orchestrates the structural shutdown of its unit
|
||||||
|
# fibers) and give it room to run their finalizers before the cgroup is reaped.
|
||||||
|
KillMode = "mixed";
|
||||||
|
KillSignal = "SIGTERM";
|
||||||
|
TimeoutStopSec = 30;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-2
@@ -56,6 +56,12 @@
|
|||||||
gsettings-desktop-schemas,
|
gsettings-desktop-schemas,
|
||||||
adwaita-icon-theme,
|
adwaita-icon-theme,
|
||||||
vulkan-headers,
|
vulkan-headers,
|
||||||
|
# web console (punktfunk-web): a bun-built Nitro SSR bundle, run on bun.
|
||||||
|
bun,
|
||||||
|
nodejs,
|
||||||
|
makeWrapper,
|
||||||
|
cacert,
|
||||||
|
stdenvNoCC,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
gbm = if libgbm != null then libgbm else mesa;
|
gbm = if libgbm != null then libgbm else mesa;
|
||||||
@@ -101,12 +107,34 @@ let
|
|||||||
platforms = [ "x86_64-linux" ]; # NVENC desktop host; matches the RPM's ExclusiveArch: x86_64
|
platforms = [ "x86_64-linux" ]; # NVENC desktop host; matches the RPM's ExclusiveArch: x86_64
|
||||||
maintainers = [ ];
|
maintainers = [ ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The Linux status tray, built in ISOLATION from the host — this is load-bearing, not tidiness.
|
||||||
|
# Cargo unifies features across every package in a single `cargo build`, so co-building the tray
|
||||||
|
# with the host would pull the host's `ashpd → zbus/tokio` onto the tray's SHARED `zbus`. The tray
|
||||||
|
# deliberately runs zbus on `ksni`'s `async-io` executor with the `blocking` API and no tokio
|
||||||
|
# runtime (see crates/punktfunk-tray/Cargo.toml), so a tokio-flavoured zbus panics at startup:
|
||||||
|
# "there is no reactor running, must be called from the context of a Tokio 1.x runtime". A separate
|
||||||
|
# invocation keeps the tray's zbus on async-io. (The host package copies this binary into its $out.)
|
||||||
|
punktfunk-tray = craneLib.buildPackage (commonArgs // {
|
||||||
|
pname = "punktfunk-tray";
|
||||||
|
cargoExtraArgs = "--locked -p punktfunk-tray";
|
||||||
|
PUNKTFUNK_BUILD_VERSION = buildVersion;
|
||||||
|
# Pure-Rust leaf: ksni/zbus talk to the dbus socket, ureq+rustls(ring) + punktfunk-core `tls` —
|
||||||
|
# nothing to link (no buildInputs) and no GPU driver runpath (it never dlopens libcuda/EGL/vulkan).
|
||||||
|
meta = meta // {
|
||||||
|
description = "punktfunk host status tray (Linux StatusNotifierItem)";
|
||||||
|
mainProgram = "punktfunk-tray";
|
||||||
|
};
|
||||||
|
});
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
inherit punktfunk-tray;
|
||||||
|
|
||||||
punktfunk-host = craneLib.buildPackage (commonArgs // {
|
punktfunk-host = craneLib.buildPackage (commonArgs // {
|
||||||
pname = "punktfunk-host";
|
pname = "punktfunk-host";
|
||||||
|
# HOST ONLY — the tray is a separate derivation (see the note above; co-building crashes it).
|
||||||
cargoExtraArgs =
|
cargoExtraArgs =
|
||||||
"--locked -p punktfunk-host -p punktfunk-tray "
|
"--locked -p punktfunk-host "
|
||||||
+ "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode";
|
+ "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode";
|
||||||
|
|
||||||
PUNKTFUNK_BUILD_VERSION = buildVersion;
|
PUNKTFUNK_BUILD_VERSION = buildVersion;
|
||||||
@@ -126,6 +154,10 @@ in
|
|||||||
# the host's authorization .desktop must name the ACTUAL binary path (the store path here). The
|
# the host's authorization .desktop must name the ACTUAL binary path (the store path here). The
|
||||||
# tray/client .desktop Exec lines are rewritten for the same reason (menu launch / autostart).
|
# tray/client .desktop Exec lines are rewritten for the same reason (menu launch / autostart).
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
|
# The status tray, built in its own derivation (see the punktfunk-tray note above) so its zbus
|
||||||
|
# stays on async-io; ship it in the host's $out so the tray .desktop below resolves it.
|
||||||
|
install -Dm0755 ${punktfunk-tray}/bin/punktfunk-tray "$out/bin/punktfunk-tray"
|
||||||
|
|
||||||
# udev: /dev/uinput + /dev/uhid (virtual gamepads) + the vhci sysfs perms for the virtual Deck.
|
# udev: /dev/uinput + /dev/uhid (virtual gamepads) + the vhci sysfs perms for the virtual Deck.
|
||||||
install -Dm0644 scripts/60-punktfunk.rules "$out/lib/udev/rules.d/60-punktfunk.rules"
|
install -Dm0644 scripts/60-punktfunk.rules "$out/lib/udev/rules.d/60-punktfunk.rules"
|
||||||
|
|
||||||
@@ -161,7 +193,8 @@ in
|
|||||||
# empty at build time): append the driver runpath so the runtime dlopen of libcuda.so.1 /
|
# empty at build time): append the driver runpath so the runtime dlopen of libcuda.so.1 /
|
||||||
# libnvidia-encode.so.1 / libEGL.so.1 / the GPU's libvulkan ICD resolves from the running system.
|
# libnvidia-encode.so.1 / libEGL.so.1 / the GPU's libvulkan ICD resolves from the running system.
|
||||||
postFixup = ''
|
postFixup = ''
|
||||||
addDriverRunpath "$out/bin/punktfunk-host" "$out/bin/punktfunk-tray"
|
# Only the host dlopens the GPU stack; the tray (its own derivation, copied in above) does not.
|
||||||
|
addDriverRunpath "$out/bin/punktfunk-host"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta = meta // {
|
meta = meta // {
|
||||||
@@ -232,4 +265,190 @@ in
|
|||||||
mainProgram = "punktfunk-client";
|
mainProgram = "punktfunk-client";
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
# --- management web console (punktfunk-web) ------------------------------------------------------
|
||||||
|
# The browser console every client needs for SPAKE2 PIN pairing + host status: a TanStack Start /
|
||||||
|
# React app that vite builds into a Nitro SSR bundle, run on `bun` (the Nitro `bun` preset + a
|
||||||
|
# custom Bun.serve TLS entry — node can't run it; web/nitro-entry/bun-https.mjs). This mirrors the
|
||||||
|
# Debian punktfunk-web .deb (packaging/debian/build-web-deb.sh) and the RPM's `--with web`
|
||||||
|
# subpackage, which the host package Recommends so a default install pulls the console too.
|
||||||
|
#
|
||||||
|
# Unlike apt/dnf — which have no bun in their repos and so VENDOR a bun binary into the package —
|
||||||
|
# Nix has `pkgs.bun`, so the launcher just execs it from the store (no vendored runtime). The
|
||||||
|
# systemd `--user` units + firewall wiring live in the NixOS module, pointed at this store path.
|
||||||
|
punktfunk-web =
|
||||||
|
let
|
||||||
|
# Offline node_modules for the console. `bun install` needs the network AND the @unom npm
|
||||||
|
# registry (web/.npmrc → https://git.unom.io/api/packages/unom/npm/, read-public: the same
|
||||||
|
# anonymous pull CI's rpm/deb builds do), so it lives in a fixed-output derivation — FODs get
|
||||||
|
# network, and `outputHash` pins the result. `--ignore-scripts` skips the install-time
|
||||||
|
# `prepare` codegen (it wants ../api/openapi.json, outside this web-only src scope); the build
|
||||||
|
# derivation below runs codegen itself where the whole tree is present.
|
||||||
|
#
|
||||||
|
# ⚠ When web/bun.lock changes, this hash must be refreshed: set `outputHash = lib.fakeHash`,
|
||||||
|
# rebuild, and copy the sha256 Nix reports back here (see packaging/nix/README.md).
|
||||||
|
webDeps = stdenvNoCC.mkDerivation {
|
||||||
|
pname = "punktfunk-web-deps";
|
||||||
|
inherit version;
|
||||||
|
src = src + "/web";
|
||||||
|
nativeBuildInputs = [ bun cacert ];
|
||||||
|
dontConfigure = true;
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
export HOME=$TMPDIR
|
||||||
|
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
|
||||||
|
export SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
|
export NODE_EXTRA_CA_CERTS=$SSL_CERT_FILE
|
||||||
|
# copyfile backend ⇒ node_modules is fully materialised (no links into the ephemeral
|
||||||
|
# cache), so the tree survives the copy into the content-addressed $out.
|
||||||
|
bun install --frozen-lockfile --ignore-scripts --no-progress --backend=copyfile
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out
|
||||||
|
cp -R node_modules $out/node_modules
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
dontFixup = true;
|
||||||
|
outputHashMode = "recursive";
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHash = "sha256-OA4NjwapsCV/z+0rftDCMAQJGWw63Mi/GARetmuy0QU="; # web/bun.lock deps (refresh on lockfile change; see README).
|
||||||
|
};
|
||||||
|
in
|
||||||
|
stdenvNoCC.mkDerivation {
|
||||||
|
pname = "punktfunk-web";
|
||||||
|
inherit src version;
|
||||||
|
# nodejs: the JS build tools' `.bin` shims are `#!/usr/bin/env node`; patchShebangs (below)
|
||||||
|
# repoints them at this node so they run in the sandbox. bun is still the RUNTIME (the launcher
|
||||||
|
# execs it); node is build-time only, for orval/paraglide/vite.
|
||||||
|
nativeBuildInputs = [ bun nodejs makeWrapper ];
|
||||||
|
|
||||||
|
# No cross-derivation dep cache: codegen + the vite build are fully offline (every input is in
|
||||||
|
# the vendored node_modules, the checked-in api/openapi.json, and web/project.inlang).
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
export HOME=$TMPDIR
|
||||||
|
cp -R ${webDeps}/node_modules web/node_modules
|
||||||
|
chmod -R u+w web/node_modules
|
||||||
|
# The JS CLIs (orval, paraglide-js, vite, …) ship a `#!/usr/bin/env node` shebang, and the
|
||||||
|
# build sandbox has no /usr/bin/env — rewrite them to the store `node` before running any
|
||||||
|
# script (else `bun run codegen` dies with "bad interpreter: /usr/bin/env"). Patch the WHOLE
|
||||||
|
# node_modules, not just .bin: bun's .bin entries are symlinks (skipped by patchShebangs'
|
||||||
|
# `-type f`); the real shebang lives in each package's `dist/bin/*.js` that they point to.
|
||||||
|
patchShebangs web/node_modules
|
||||||
|
cd web
|
||||||
|
# `codegen` = orval (a typed React-Query client from ../api/openapi.json) + paraglide-js i18n
|
||||||
|
# compile; both write into src/ and are prerequisites of the build (normally the install-time
|
||||||
|
# `prepare` hook, which was skipped in the deps FOD).
|
||||||
|
bun run codegen
|
||||||
|
# `build` = vite build ⇒ the Nitro `bun`-preset SSR bundle in .output (our Bun.serve TLS entry).
|
||||||
|
bun run build
|
||||||
|
# Guard: assert we produced the bun bundle, not a node one (same check the deb/rpm builders do).
|
||||||
|
grep -q 'Bun\.serve' .output/server/index.mjs \
|
||||||
|
|| { echo "ERROR: web/.output is not a bun bundle (wrong nitro preset)" >&2; exit 1; }
|
||||||
|
cd ..
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
# The SSR bundle + its static assets, plus the first-run helper and env sample.
|
||||||
|
mkdir -p $out/share/punktfunk-web/.output
|
||||||
|
cp -R web/.output/server $out/share/punktfunk-web/.output/server
|
||||||
|
cp -R web/.output/public $out/share/punktfunk-web/.output/public
|
||||||
|
install -Dm0755 scripts/web-init.sh $out/share/punktfunk-web/web-init.sh
|
||||||
|
install -Dm0644 web/web.env.example $out/share/punktfunk-web/web.env.example
|
||||||
|
|
||||||
|
# PATH-stable launcher: run the SSR bundle on bun from the store (mirrors the deb/rpm
|
||||||
|
# /usr/bin/punktfunk-web-server, minus the vendored-bun indirection).
|
||||||
|
makeWrapper ${bun}/bin/bun $out/bin/punktfunk-web-server \
|
||||||
|
--add-flags "$out/share/punktfunk-web/.output/server/index.mjs"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
dontFixup = true;
|
||||||
|
|
||||||
|
meta = meta // {
|
||||||
|
description = "punktfunk management web console (Nitro SSR on bun + React)";
|
||||||
|
mainProgram = "punktfunk-web-server";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# --- plugin/script runner (punktfunk-scripting) --------------------------------------------------
|
||||||
|
# The host's automation runner: the `@punktfunk/host` SDK's `punktfunk-scripting` CLI (built on
|
||||||
|
# Effect), which discovers ~/.config/punktfunk/{scripts,plugins} and supervises each unit as an
|
||||||
|
# Effect fiber. It runs on `bun` (it import()s the operator's `.ts` plugin files, which only bun
|
||||||
|
# can do). Mirrors the Debian punktfunk-scripting .deb / the RPM's `--with scripting` subpackage,
|
||||||
|
# which the host package Recommends — the NixOS module wires the opt-in systemd --user unit.
|
||||||
|
#
|
||||||
|
# Unlike the deb/rpm we don't `bun build` into a bundle + vendor bun; we still bundle (one
|
||||||
|
# self-contained JS, effect inlined) but the launcher execs `pkgs.bun` from the store.
|
||||||
|
punktfunk-scripting =
|
||||||
|
let
|
||||||
|
# Offline node_modules for the SDK build — same fixed-output pattern as punktfunk-web's webDeps
|
||||||
|
# (`bun install` needs the network). ⚠ Refresh `outputHash` when sdk/bun.lock changes (set
|
||||||
|
# lib.fakeHash, rebuild, copy the printed sha256 — see packaging/nix/README.md).
|
||||||
|
sdkDeps = stdenvNoCC.mkDerivation {
|
||||||
|
pname = "punktfunk-scripting-deps";
|
||||||
|
inherit version;
|
||||||
|
src = src + "/sdk";
|
||||||
|
nativeBuildInputs = [ bun cacert ];
|
||||||
|
dontConfigure = true;
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
export HOME=$TMPDIR
|
||||||
|
export BUN_INSTALL_CACHE_DIR=$TMPDIR/bun-cache
|
||||||
|
export SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
|
export NODE_EXTRA_CA_CERTS=$SSL_CERT_FILE
|
||||||
|
bun install --frozen-lockfile --ignore-scripts --no-progress --backend=copyfile
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out
|
||||||
|
cp -R node_modules $out/node_modules
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
dontFixup = true;
|
||||||
|
outputHashMode = "recursive";
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHash = "sha256-+KCKCA0q0bwTxr7bsA3X4DbT/8nUjJA/JIoJU6BfiZw="; # sdk/bun.lock deps (refresh on lockfile change; see README).
|
||||||
|
};
|
||||||
|
in
|
||||||
|
stdenvNoCC.mkDerivation {
|
||||||
|
pname = "punktfunk-scripting";
|
||||||
|
inherit src version;
|
||||||
|
nativeBuildInputs = [ bun makeWrapper ];
|
||||||
|
|
||||||
|
# `bun build --target=bun` bundles the runner CLI to ONE self-contained JS: effect + the SDK
|
||||||
|
# are inlined, and the runner's dynamic `import()` of the operator's plugin files is left as a
|
||||||
|
# runtime import (bun keeps unresolvable dynamic specifiers external). Fully offline.
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
export HOME=$TMPDIR
|
||||||
|
cp -R ${sdkDeps}/node_modules sdk/node_modules
|
||||||
|
chmod -R u+w sdk/node_modules
|
||||||
|
( cd sdk && bun build src/runner-cli.ts --target=bun --outfile=$TMPDIR/runner-cli.js )
|
||||||
|
grep -q 'attempt=' $TMPDIR/runner-cli.js \
|
||||||
|
|| { echo "ERROR: runner bundle missing the dynamic plugin import — wrong build" >&2; exit 1; }
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
install -Dm0644 $TMPDIR/runner-cli.js $out/share/punktfunk-scripting/runner-cli.js
|
||||||
|
# Launcher: run the bundle on bun from the store (mirrors the deb/rpm /usr/bin/punktfunk-scripting).
|
||||||
|
makeWrapper ${bun}/bin/bun $out/bin/punktfunk-scripting \
|
||||||
|
--add-flags "$out/share/punktfunk-scripting/runner-cli.js"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
dontFixup = true;
|
||||||
|
|
||||||
|
meta = meta // {
|
||||||
|
description = "punktfunk plugin/script runner (Effect SDK on bun)";
|
||||||
|
mainProgram = "punktfunk-scripting";
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ PF_RELEASE="${PF_RELEASE:-1}"
|
|||||||
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
|
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
|
||||||
WEB_OPT=()
|
WEB_OPT=()
|
||||||
[ "${PF_WITH_WEB:-0}" = "1" ] && WEB_OPT=(--with web)
|
[ "${PF_WITH_WEB:-0}" = "1" ] && WEB_OPT=(--with web)
|
||||||
|
# PF_WITH_SCRIPTING=1 builds the punktfunk-scripting subpackage (the plugin/script runner). Same bun
|
||||||
|
# requirement as web; default off so a bare `rpmbuild`/COPR still works.
|
||||||
|
SCRIPTING_OPT=()
|
||||||
|
[ "${PF_WITH_SCRIPTING:-0}" = "1" ] && SCRIPTING_OPT=(--with scripting)
|
||||||
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
cd "$ROOTDIR"
|
cd "$ROOTDIR"
|
||||||
|
|
||||||
@@ -51,7 +55,7 @@ fi
|
|||||||
# resolves them from RPMs. Our builder image provides the toolchain via rustup (so
|
# resolves them from RPMs. Our builder image provides the toolchain via rustup (so
|
||||||
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
|
# rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which
|
||||||
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
|
# rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link.
|
||||||
rpmbuild -bb --nodeps "${WEB_OPT[@]}" \
|
rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" \
|
||||||
--define "_topdir $TOP" \
|
--define "_topdir $TOP" \
|
||||||
--define "pf_version ${PF_VERSION}" \
|
--define "pf_version ${PF_VERSION}" \
|
||||||
--define "pf_release ${PF_RELEASE}" \
|
--define "pf_release ${PF_RELEASE}" \
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ ExclusiveArch: x86_64
|
|||||||
# the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
|
# the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
|
||||||
%bcond_with web
|
%bcond_with web
|
||||||
|
|
||||||
|
# Plugin/script runner subpackage (punktfunk-scripting). OFF by default for the same reason as web:
|
||||||
|
# building the bun bundle needs `bun`, absent from a plain rpmbuild / COPR mock chroot. CI's builder
|
||||||
|
# image has bun and builds with `--with scripting`, so the Gitea RPM registry carries it. Mirrors the
|
||||||
|
# Debian punktfunk-scripting .deb.
|
||||||
|
%bcond_with scripting
|
||||||
|
|
||||||
# --- Build toolchain ---------------------------------------------------------
|
# --- Build toolchain ---------------------------------------------------------
|
||||||
BuildRequires: cargo
|
BuildRequires: cargo
|
||||||
BuildRequires: rust
|
BuildRequires: rust
|
||||||
@@ -112,6 +118,10 @@ Recommends: intel-media-driver
|
|||||||
# Weak-dep so `dnf install punktfunk` pulls it where it exists (the Gitea registry); harmless where
|
# Weak-dep so `dnf install punktfunk` pulls it where it exists (the Gitea registry); harmless where
|
||||||
# it doesn't (a COPR build without `--with web` simply has no punktfunk-web to satisfy).
|
# it doesn't (a COPR build without `--with web` simply has no punktfunk-web to satisfy).
|
||||||
Recommends: punktfunk-web
|
Recommends: punktfunk-web
|
||||||
|
# The plugin/script runner (host automation on bun). Same weak-dep story: pulled where it exists,
|
||||||
|
# harmless where a `--with scripting`-less build didn't produce it. Its systemd --user unit ships
|
||||||
|
# disabled — the runner is inert until you add scripts/plugins.
|
||||||
|
Recommends: punktfunk-scripting
|
||||||
|
|
||||||
%description
|
%description
|
||||||
punktfunk is a Linux-first, low-latency desktop and game streaming host. It speaks
|
punktfunk is a Linux-first, low-latency desktop and game streaming host. It speaks
|
||||||
@@ -156,6 +166,23 @@ mgmt token, identity cert, and a generated login password, no env editing. Bundl
|
|||||||
runtime. Enable with `systemctl --user enable --now punktfunk-web`.
|
runtime. Enable with `systemctl --user enable --now punktfunk-web`.
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
%if %{with scripting}
|
||||||
|
%package scripting
|
||||||
|
Summary: punktfunk plugin/script runner (Effect SDK on bun)
|
||||||
|
# Runtime is BUN — the runner import()s the operator's .ts plugin files, which only bun can do. bun
|
||||||
|
# isn't in Fedora repos, so we VENDOR it into the package (arch-specific, not noarch). The runner
|
||||||
|
# itself is bundled to ONE self-contained JS (effect + SDK inlined), so no node_modules ship.
|
||||||
|
|
||||||
|
%description scripting
|
||||||
|
The plugin/script runner for a punktfunk streaming host: it discovers loose scripts under
|
||||||
|
~/.config/punktfunk/scripts and installed punktfunk-plugin-* packages under ~/.config/punktfunk/
|
||||||
|
plugins, and supervises each as an Effect fiber (capped-jittered restart; SIGTERM shuts the whole
|
||||||
|
tree down structurally so plugin finalizers run). A plugin auto-wires to the host's mgmt token +
|
||||||
|
identity cert on the same box — no env editing. Bundles its own bun runtime. OPT-IN: the systemd
|
||||||
|
--user unit ships disabled (the runner is inert until you add scripts/plugins). Enable with
|
||||||
|
`systemctl --user enable --now punktfunk-scripting`.
|
||||||
|
%endif
|
||||||
|
|
||||||
%prep
|
%prep
|
||||||
%autosetup -n %{name}-%{version}
|
%autosetup -n %{name}-%{version}
|
||||||
|
|
||||||
@@ -185,7 +212,13 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
|
|||||||
# Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts
|
# Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts
|
||||||
# back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully.
|
# back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully.
|
||||||
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \
|
||||||
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray
|
-p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
|
||||||
|
# The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features
|
||||||
|
# across everything in one build, so co-building the tray with the host pulls the host's
|
||||||
|
# ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio
|
||||||
|
# runtime by design) then panics at startup ("there is no reactor running, must be called from the
|
||||||
|
# context of a Tokio 1.x runtime"). Built alone, its zbus stays on async-io. (Same split the .deb does.)
|
||||||
|
cargo build --release --locked -p punktfunk-tray
|
||||||
|
|
||||||
%if %{with web}
|
%if %{with web}
|
||||||
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
|
||||||
@@ -197,6 +230,18 @@ if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then
|
|||||||
fi
|
fi
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
%if %{with scripting}
|
||||||
|
# Plugin/script runner: bundle the SDK's runner CLI to ONE self-contained JS with bun
|
||||||
|
# (`--target=bun` inlines effect + the SDK; the dynamic plugin import stays a runtime import). bun is
|
||||||
|
# both the build tool AND the vendored runtime (in %%install below).
|
||||||
|
(cd sdk && bun install --frozen-lockfile --ignore-scripts && \
|
||||||
|
bun build src/runner-cli.ts --target=bun --outfile=../runner-cli.js)
|
||||||
|
if ! grep -q 'attempt=' runner-cli.js; then
|
||||||
|
echo "ERROR: runner bundle missing the dynamic plugin import — wrong build" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
%endif
|
||||||
|
|
||||||
%install
|
%install
|
||||||
# Binary
|
# Binary
|
||||||
install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host
|
install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host
|
||||||
@@ -316,6 +361,22 @@ install -Dm0755 scripts/web-init.sh %{buildroot}%{_datadir}/punkt
|
|||||||
install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punktfunk-web/web.env.example
|
install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punktfunk-web/web.env.example
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
%if %{with scripting}
|
||||||
|
# --- plugin/script runner subpackage (punktfunk-scripting) ---
|
||||||
|
install -Dm0644 runner-cli.js %{buildroot}%{_datadir}/punktfunk-scripting/runner-cli.js
|
||||||
|
# Vendor the build env's bun (arch-specific, like the web subpackage) into a private libexec dir.
|
||||||
|
install -Dm0755 "$(command -v bun)" %{buildroot}%{_libexecdir}/punktfunk-scripting/bun
|
||||||
|
# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-scripting) — runs the bundle on bun.
|
||||||
|
cat > %{buildroot}%{_bindir}/punktfunk-scripting <<'WRAP'
|
||||||
|
#!/bin/sh
|
||||||
|
exec /usr/libexec/punktfunk-scripting/bun /usr/share/punktfunk-scripting/runner-cli.js "$@"
|
||||||
|
WRAP
|
||||||
|
chmod 0755 %{buildroot}%{_bindir}/punktfunk-scripting
|
||||||
|
# systemd --user unit — installed but NOT auto-enabled (opt-in; the runner is inert until you add
|
||||||
|
# scripts/plugins). Enable with `systemctl --user enable --now punktfunk-scripting`.
|
||||||
|
install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/punktfunk-scripting.service
|
||||||
|
%endif
|
||||||
|
|
||||||
%files
|
%files
|
||||||
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||||
%doc README.md packaging/README.md
|
%doc README.md packaging/README.md
|
||||||
@@ -360,6 +421,17 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
|
|||||||
%{_userunitdir}/punktfunk-web-init.service
|
%{_userunitdir}/punktfunk-web-init.service
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
%if %{with scripting}
|
||||||
|
%files scripting
|
||||||
|
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
|
||||||
|
%{_bindir}/punktfunk-scripting
|
||||||
|
%dir %{_libexecdir}/punktfunk-scripting
|
||||||
|
%{_libexecdir}/punktfunk-scripting/bun
|
||||||
|
%dir %{_datadir}/punktfunk-scripting
|
||||||
|
%{_datadir}/punktfunk-scripting/runner-cli.js
|
||||||
|
%{_userunitdir}/punktfunk-scripting.service
|
||||||
|
%endif
|
||||||
|
|
||||||
%post client
|
%post client
|
||||||
# Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it
|
# Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it
|
||||||
# applies on the next boot into the layered deployment).
|
# applies on the next boot into the layered deployment).
|
||||||
@@ -403,7 +475,17 @@ echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generat
|
|||||||
echo "Then open https://<host-ip>:47992"
|
echo "Then open https://<host-ip>:47992"
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
%if %{with scripting}
|
||||||
|
%post scripting
|
||||||
|
echo "punktfunk-scripting installed. It runs your automation — add scripts to"
|
||||||
|
echo " ~/.config/punktfunk/scripts/ (loose .ts/.js files)"
|
||||||
|
echo "or install plugins into ~/.config/punktfunk/plugins/ (bun add punktfunk-plugin-<name>),"
|
||||||
|
echo "then enable the runner: systemctl --user enable --now punktfunk-scripting"
|
||||||
|
%endif
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Thu Jul 17 2026 punktfunk <noreply@anthropic.com> - 0.0.1-3
|
||||||
|
- Add punktfunk-scripting subpackage (plugin/script runner, --with scripting; bun-bundled Effect SDK).
|
||||||
* Mon Jun 15 2026 punktfunk <noreply@anthropic.com> - 0.0.1-2
|
* Mon Jun 15 2026 punktfunk <noreply@anthropic.com> - 0.0.1-2
|
||||||
- Add punktfunk-web subpackage (management console, --with web; auto-wired to the host token).
|
- Add punktfunk-web subpackage (management console, --with web; auto-wired to the host token).
|
||||||
* Wed Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1
|
* Wed Jun 10 2026 punktfunk <noreply@anthropic.com> - 0.0.1-1
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ param(
|
|||||||
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
|
||||||
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
|
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
|
||||||
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
|
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
|
||||||
[string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console
|
[string]$ScriptingBundle = $env:SCRIPTING_BUNDLE, # built runner-cli.js -> bundle the plugin/script runner
|
||||||
|
[string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console + runner
|
||||||
[string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic
|
[string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic
|
||||||
[switch]$NoDriver, # build without the bundled pf-vdisplay driver
|
[switch]$NoDriver, # build without the bundled pf-vdisplay driver
|
||||||
[switch]$NoSign # skip signing (local debug)
|
[switch]$NoSign # skip signing (local debug)
|
||||||
@@ -257,28 +258,50 @@ if ($ffmpegBinSrc -and (Test-Path $ffmpegBinSrc)) {
|
|||||||
}
|
}
|
||||||
else { Write-Host "no FFMPEG_DIR\bin -> installer built WITHOUT FFmpeg DLLs (nvenc/software-only host)" }
|
else { Write-Host "no FFMPEG_DIR\bin -> installer built WITHOUT FFmpeg DLLs (nvenc/software-only host)" }
|
||||||
|
|
||||||
# --- stage the web management console (the self-contained .output tree + a portable bun + launcher) -
|
# --- stage the bun runtime + the two bun payloads (web console, plugin/script runner) --------------
|
||||||
# The console runs as the PunktfunkWeb scheduled task (`bun {app}\web\.output\server\index.mjs`),
|
# Both the web console and the runner run on bun. Stage everything ISCC reads into $OutDir (the
|
||||||
# auto-wired to the host's loopback mgmt API. Stage everything ISCC reads into $OutDir (the
|
# non-WOW64-redirected C:\t area, same reason as the .iss/host.env staging above). bun is staged ONCE
|
||||||
# non-WOW64-redirected C:\t area, same reason as the .iss/host.env staging above). The .output is
|
# and shared: the two payloads pass their own defines and the .iss keys WithWeb / WithScripting on
|
||||||
# self-contained (Nitro noExternals - deps bundled + tree-shaken, no node_modules), so bun runs it
|
# (their dir + BunExe). Each payload is omitted when its inputs are unset (e.g. a local debug pack).
|
||||||
# directly; omitted when -WebDir/-BunExe are unset (host-only installer, e.g. a local debug pack).
|
$haveBun = $BunExe -and (Test-Path $BunExe)
|
||||||
if ($WebDir -and (Test-Path $WebDir) -and $BunExe -and (Test-Path $BunExe)) {
|
$wantWeb = $WebDir -and (Test-Path $WebDir) -and $haveBun
|
||||||
|
$wantScripting = $ScriptingBundle -and (Test-Path $ScriptingBundle) -and $haveBun
|
||||||
|
if ($wantWeb -or $wantScripting) {
|
||||||
|
$bunStage = Join-Path $OutDir 'bun.exe'
|
||||||
|
Copy-Item -LiteralPath $BunExe -Destination $bunStage -Force
|
||||||
|
$defines += "/DBunExe=$bunStage"
|
||||||
|
}
|
||||||
|
# The web console: the self-contained .output tree (Nitro noExternals - deps bundled + tree-shaken,
|
||||||
|
# no node_modules), run by the PunktfunkWeb scheduled task, auto-wired to the host's loopback mgmt API.
|
||||||
|
if ($wantWeb) {
|
||||||
$webStage = Join-Path $OutDir 'web'
|
$webStage = Join-Path $OutDir 'web'
|
||||||
if (Test-Path $webStage) { Remove-Item $webStage -Recurse -Force }
|
if (Test-Path $webStage) { Remove-Item $webStage -Recurse -Force }
|
||||||
New-Item -ItemType Directory -Force -Path $webStage | Out-Null
|
New-Item -ItemType Directory -Force -Path $webStage | Out-Null
|
||||||
Copy-Item (Join-Path $WebDir '*') -Destination $webStage -Recurse -Force
|
Copy-Item (Join-Path $WebDir '*') -Destination $webStage -Recurse -Force
|
||||||
$bunStage = Join-Path $OutDir 'bun.exe'
|
|
||||||
Copy-Item -LiteralPath $BunExe -Destination $bunStage -Force
|
|
||||||
$webRun = Join-Path $OutDir 'web-run.cmd'
|
$webRun = Join-Path $OutDir 'web-run.cmd'
|
||||||
Copy-Item (Join-Path $repoRoot 'scripts\windows\web-run.cmd') -Destination $webRun -Force
|
Copy-Item (Join-Path $repoRoot 'scripts\windows\web-run.cmd') -Destination $webRun -Force
|
||||||
# The console is provisioned by `punktfunk-host.exe web setup` (not a staged web-setup.ps1).
|
# The console is provisioned by `punktfunk-host.exe web setup` (not a staged web-setup.ps1).
|
||||||
$defines += "/DWebDir=$webStage"
|
$defines += "/DWebDir=$webStage"
|
||||||
$defines += "/DBunExe=$bunStage"
|
|
||||||
$defines += "/DWebRunCmd=$webRun"
|
$defines += "/DWebRunCmd=$webRun"
|
||||||
Write-Host "bundling the web console from $WebDir (+ bun $BunExe)"
|
Write-Host "bundling the web console from $WebDir (+ bun $BunExe)"
|
||||||
}
|
}
|
||||||
else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" }
|
else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" }
|
||||||
|
# The plugin/script runner: one self-contained bundle (effect + the SDK inlined). Its scheduled task
|
||||||
|
# is registered DISABLED (opt-in) by the installer. Built by CI (SCRIPTING_BUNDLE) alongside the web
|
||||||
|
# console; omitted when -ScriptingBundle/-BunExe are unset.
|
||||||
|
if ($wantScripting) {
|
||||||
|
$scrStage = Join-Path $OutDir 'scripting'
|
||||||
|
if (Test-Path $scrStage) { Remove-Item $scrStage -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Force -Path $scrStage | Out-Null
|
||||||
|
$scrBundle = Join-Path $scrStage 'runner-cli.js'
|
||||||
|
Copy-Item -LiteralPath $ScriptingBundle -Destination $scrBundle -Force
|
||||||
|
$scrRun = Join-Path $scrStage 'scripting-run.cmd'
|
||||||
|
Copy-Item (Join-Path $repoRoot 'scripts\windows\scripting-run.cmd') -Destination $scrRun -Force
|
||||||
|
$defines += "/DScriptingBundle=$scrBundle"
|
||||||
|
$defines += "/DScriptingRunCmd=$scrRun"
|
||||||
|
Write-Host "bundling the plugin/script runner from $ScriptingBundle (+ bun $BunExe)"
|
||||||
|
}
|
||||||
|
else { Write-Host "no -ScriptingBundle/-BunExe -> installer built WITHOUT the plugin/script runner" }
|
||||||
|
|
||||||
# --- build + stage the HDR Vulkan layer (pf-vkhdr-layer) --------------------------------------
|
# --- build + stage the HDR Vulkan layer (pf-vkhdr-layer) --------------------------------------
|
||||||
# A tiny always-on Vulkan implicit layer (cdylib) that advertises HDR10/scRGB surface formats on the
|
# A tiny always-on Vulkan implicit layer (cdylib) that advertises HDR10/scRGB surface formats on the
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
#ifndef WebRunCmd
|
#ifndef WebRunCmd
|
||||||
#define WebRunCmd "..\..\scripts\windows\web-run.cmd"
|
#define WebRunCmd "..\..\scripts\windows\web-run.cmd"
|
||||||
#endif
|
#endif
|
||||||
|
; The plugin/script runner launcher (the action the opt-in PunktfunkScripting task runs) - staged
|
||||||
|
; next to the .iss by pack-host-installer.ps1 (absolute path passed in).
|
||||||
|
#ifndef ScriptingRunCmd
|
||||||
|
#define ScriptingRunCmd "..\..\scripts\windows\scripting-run.cmd"
|
||||||
|
#endif
|
||||||
; StageDir (the staged pf-vdisplay payload + nefconc.exe + install-pf-vdisplay.ps1) is optional.
|
; StageDir (the staged pf-vdisplay payload + nefconc.exe + install-pf-vdisplay.ps1) is optional.
|
||||||
#ifdef StageDir
|
#ifdef StageDir
|
||||||
#define WithDriver
|
#define WithDriver
|
||||||
@@ -66,6 +71,13 @@
|
|||||||
#define WithWeb
|
#define WithWeb
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
; ScriptingBundle (the built runner-cli.js) + BunExe are passed together by pack-host-installer.ps1
|
||||||
|
; to bundle the plugin/script runner. Both required -> WithScripting.
|
||||||
|
#ifdef ScriptingBundle
|
||||||
|
#ifdef BunExe
|
||||||
|
#define WithScripting
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
; VkLayerDir (the staged pf-vkhdr-layer: pf_vkhdr_layer.dll + .json) is optional - present when the
|
; VkLayerDir (the staged pf-vkhdr-layer: pf_vkhdr_layer.dll + .json) is optional - present when the
|
||||||
; HDR Vulkan layer was built. It lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the
|
; HDR Vulkan layer was built. It lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the
|
||||||
; virtual display (the ICD won't advertise HDR there; the layer injects the surface formats, self-
|
; virtual display (the ICD won't advertise HDR there; the layer injects the surface formats, self-
|
||||||
@@ -179,15 +191,26 @@ Source: "{#LicensesDir}\*"; DestDir: "{app}\licenses"; Flags: ignoreversion
|
|||||||
; dynamically (replaceable DLLs) - FFmpeg is used under the LGPL v2.1+; see {app}\licenses.
|
; dynamically (replaceable DLLs) - FFmpeg is used under the LGPL v2.1+; see {app}\licenses.
|
||||||
Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
#endif
|
#endif
|
||||||
|
; The portable bun runtime -> {app}\bun\bun.exe. Shared by the web console AND the plugin/script
|
||||||
|
; runner (both run on bun), so stage it once when EITHER is bundled.
|
||||||
|
#if defined(WithWeb) || defined(WithScripting)
|
||||||
|
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
|
||||||
|
#endif
|
||||||
#ifdef WithWeb
|
#ifdef WithWeb
|
||||||
; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps
|
; The web management console: the self-contained Nitro SSR bundle (.output = server + public; deps
|
||||||
; bundled in, no node_modules) -> {app}\web\.output, a portable bun runtime -> {app}\bun\bun.exe, and
|
; bundled in, no node_modules) -> {app}\web\.output, and the launcher the PunktfunkWeb task runs ->
|
||||||
; the launcher the PunktfunkWeb task runs -> {app}\web\web-run.cmd. (`punktfunk-host.exe web setup`
|
; {app}\web\web-run.cmd. (`punktfunk-host.exe web setup` provisions the console at install time - no
|
||||||
; provisions the console at install time - no staged provisioner script.)
|
; staged provisioner script.)
|
||||||
Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs
|
Source: "{#WebDir}\*"; DestDir: "{app}\web\.output"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
Source: "{#BunExe}"; DestDir: "{app}\bun"; DestName: "bun.exe"; Flags: ignoreversion
|
|
||||||
Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion
|
Source: "{#WebRunCmd}"; DestDir: "{app}\web"; DestName: "web-run.cmd"; Flags: ignoreversion
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef WithScripting
|
||||||
|
; The plugin/script runner: one self-contained bundle (effect + the SDK inlined) -> {app}\scripting\
|
||||||
|
; runner-cli.js, and the launcher the (opt-in) PunktfunkScripting task runs -> {app}\scripting\
|
||||||
|
; scripting-run.cmd. Runs on the shared bun above.
|
||||||
|
Source: "{#ScriptingBundle}"; DestDir: "{app}\scripting"; DestName: "runner-cli.js"; Flags: ignoreversion
|
||||||
|
Source: "{#ScriptingRunCmd}"; DestDir: "{app}\scripting"; DestName: "scripting-run.cmd"; Flags: ignoreversion
|
||||||
|
#endif
|
||||||
#ifdef WithDriver
|
#ifdef WithDriver
|
||||||
; The driver payload + nefconc.exe + install-pf-vdisplay.ps1, extracted to {tmp} and removed after install.
|
; The driver payload + nefconc.exe + install-pf-vdisplay.ps1, extracted to {tmp} and removed after install.
|
||||||
Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver
|
Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver
|
||||||
@@ -255,6 +278,16 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "
|
|||||||
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
|
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
|
#endif
|
||||||
|
#ifdef WithScripting
|
||||||
|
; Register the plugin/script runner's scheduled task (boot, SYSTEM, restart-on-failure) but leave it
|
||||||
|
; DISABLED - the runner is OPT-IN (inert until you add scripts/plugins). Enable it when ready:
|
||||||
|
; Enable-ScheduledTask -TaskName PunktfunkScripting
|
||||||
|
; Best-effort (-ErrorAction SilentlyContinue): a task hiccup never fails the whole install. No braces
|
||||||
|
; 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 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; $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
|
||||||
|
#endif
|
||||||
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
|
; 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.
|
; icon appears without waiting for the next sign-in.
|
||||||
Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon
|
Filename: "{app}\punktfunk-tray.exe"; Flags: runasoriginaluser nowait skipifsilent; Tasks: trayicon
|
||||||
@@ -283,6 +316,13 @@ Filename: "powershell.exe"; \
|
|||||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 47992,3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
|
||||||
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
|
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef WithScripting
|
||||||
|
; Stop + remove the PunktfunkScripting task (leaves %ProgramData%\punktfunk config + the operator's
|
||||||
|
; scripts/plugins, like the rest of the uninstall does). Unconditional cleanup of the task name.
|
||||||
|
Filename: "powershell.exe"; \
|
||||||
|
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkScripting -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName PunktfunkScripting -Confirm:$false -ErrorAction SilentlyContinue"""; \
|
||||||
|
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkScriptingCleanup"
|
||||||
|
#endif
|
||||||
|
|
||||||
[Code]
|
[Code]
|
||||||
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
|
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# punktfunk plugin/script runner — systemd USER unit (bun runtime, OPT-IN).
|
||||||
|
#
|
||||||
|
# Runs the operator's automation under supervision: loose files in ~/.config/punktfunk/scripts/ and
|
||||||
|
# installed `punktfunk-plugin-*` packages under ~/.config/punktfunk/plugins/. Each unit is an Effect
|
||||||
|
# fiber (a plugin restarts on failure with capped-jittered backoff; a bare script is one-shot).
|
||||||
|
# SIGTERM interrupts the whole tree STRUCTURALLY, so every plugin's scoped finalizers run before
|
||||||
|
# exit (clean deregister / preset release) — hence the generous stop timeout below.
|
||||||
|
#
|
||||||
|
# OPT-IN — unlike punktfunk-web, the package does NOT auto-enable this: the runner does nothing until
|
||||||
|
# you add scripts or install plugins. Turn it on once you have automation to run:
|
||||||
|
# systemctl --user enable --now punktfunk-scripting
|
||||||
|
#
|
||||||
|
# Auto-wired like the console: a plugin's connect() reads the host's mgmt token + identity cert from
|
||||||
|
# ~/.config/punktfunk/{mgmt-token,cert.pem} (written by the host's `serve`) — no env editing.
|
||||||
|
[Unit]
|
||||||
|
Description=punktfunk plugin/script runner
|
||||||
|
Documentation=https://git.unom.io/unom/punktfunk
|
||||||
|
# Plugins talk to the host's loopback mgmt API; order after it. Soft (ordering only, no Requires):
|
||||||
|
# the runner supervises each unit with backoff, so a plugin started before the host simply retries.
|
||||||
|
After=punktfunk-host.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/punktfunk-scripting
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
# Deliver the stop signal to the runner process itself (it orchestrates the structural shutdown of
|
||||||
|
# its unit fibers), and give it room to run their finalizers before the cgroup is reaped.
|
||||||
|
KillMode=mixed
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
rem punktfunk plugin/script runner launcher - the action the PunktfunkScripting scheduled task runs.
|
||||||
|
rem
|
||||||
|
rem OPT-IN: the installer registers that task DISABLED (the runner is inert until you add automation).
|
||||||
|
rem Enable it once you have scripts/plugins: Enable-ScheduledTask -TaskName PunktfunkScripting
|
||||||
|
rem
|
||||||
|
rem Lays out next to the installed payload: {app}\scripting\scripting-run.cmd + runner-cli.js and
|
||||||
|
rem {app}\bun\bun.exe (so %~dp0 = {app}\scripting\). The runner discovers the operator's units under
|
||||||
|
rem %ProgramData%\punktfunk\{scripts,plugins}; a plugin's connect() auto-wires to the host's mgmt
|
||||||
|
rem token + identity cert in %ProgramData%\punktfunk\ (written by the host's `serve`). No env editing.
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set "BUN=%~dp0..\bun\bun.exe"
|
||||||
|
set "RUNNER=%~dp0runner-cli.js"
|
||||||
|
|
||||||
|
if not exist "%RUNNER%" (
|
||||||
|
echo [punktfunk-scripting] runner bundle missing at "%RUNNER%".
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not exist "%BUN%" (
|
||||||
|
echo [punktfunk-scripting] bundled bun missing at "%BUN%".
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
rem The runner import()s the operator's .ts plugin files, so it runs on the bundled bun. SIGTERM (task
|
||||||
|
rem End) interrupts the whole unit tree structurally so plugin finalizers run before exit.
|
||||||
|
"%BUN%" "%RUNNER%"
|
||||||
@@ -150,6 +150,41 @@ export default definePlugin({
|
|||||||
|
|
||||||
In v1 a plugin is a script you run (see below); the managed runner package is a later step.
|
In v1 a plugin is a script you run (see below); the managed runner package is a later step.
|
||||||
|
|
||||||
|
### A plugin UI in the console — `servePluginUi`
|
||||||
|
|
||||||
|
A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the
|
||||||
|
operator. It serves the UI on a loopback ephemeral port behind a per-boot secret; `servePluginUi`
|
||||||
|
registers it with the host, and the console reverse-proxies to it and adds a nav entry gated by the
|
||||||
|
console's own session. Your code implements **zero human auth**.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePlugin, servePluginUi } from "@punktfunk/host";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
name: "rom-manager",
|
||||||
|
main: async (pf) => {
|
||||||
|
const ui = await servePluginUi(pf, {
|
||||||
|
id: "rom-manager",
|
||||||
|
title: "ROM Manager",
|
||||||
|
icon: "gamepad-2", // a lucide icon name
|
||||||
|
staticDir: new URL("../dist/ui", import.meta.url), // your built SPA
|
||||||
|
fetch: (req) => appRouter(req), // plugin-local REST/SSE (after a static miss)
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await runForever();
|
||||||
|
} finally {
|
||||||
|
await ui.close(); // deregister + stop
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Requests reach `fetch` **prefix-stripped** (the console proxy removed `/plugin-ui/<id>`), so your app
|
||||||
|
sees `/`, `/api/scan`, … — the original prefix is on `X-Forwarded-Prefix`. `servePluginUi` serves
|
||||||
|
`staticDir` first (with an `index.html` SPA fallback for navigations); return `undefined` from `fetch`
|
||||||
|
to fall through to it. Build your SPA with a relative base (`base: "./"` + hash routing) or an absolute
|
||||||
|
`base: "/plugin-ui/<id>/"`, and expect a dark canvas. Requires the Bun runtime (the runner is bun).
|
||||||
|
|
||||||
## The runner: `punktfunk-scripting`
|
## The runner: `punktfunk-scripting`
|
||||||
|
|
||||||
Instead of one unit file per script, run everything under the managed runner — it discovers
|
Instead of one unit file per script, run everything under the managed runner — it discovers
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// ── Example · console-hosted plugin UI ────────────────────────────────────────────────────────
|
||||||
|
// A plugin that surfaces a UI inside the punktfunk web console (plugin-ui-surface design). It
|
||||||
|
// serves a page + a live SSE feed on a loopback port behind a per-boot secret; `servePluginUi`
|
||||||
|
// registers it, and the console proxies to it and adds a "Demo UI" nav entry — no second password,
|
||||||
|
// no second port for the operator to learn. This also exercises the streaming path end-to-end (the
|
||||||
|
// U0 spike): the SSE feed must arrive through the console's reverse proxy unbuffered.
|
||||||
|
//
|
||||||
|
// Run under the scripting runner, or directly for a quick look: bun examples/plugin-ui.ts
|
||||||
|
import {
|
||||||
|
connect,
|
||||||
|
definePlugin,
|
||||||
|
type Punktfunk,
|
||||||
|
servePluginUi,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
// A tiny self-contained page: a heading and a list that prepends one line per SSE tick. The
|
||||||
|
// EventSource URL is RELATIVE (`./events`) so it resolves under the console proxy prefix.
|
||||||
|
const PAGE = `<!doctype html><meta charset=utf-8><title>Demo UI</title>
|
||||||
|
<style>body{font:15px system-ui;margin:2rem;color:#e5e7eb;background:#0b0b0f}li{opacity:.9}</style>
|
||||||
|
<h1>punktfunk plugin UI demo</h1><p>Live ticks (proxied SSE):</p><ul id=log></ul>
|
||||||
|
<script>
|
||||||
|
const log = document.getElementById("log");
|
||||||
|
new EventSource("./events").onmessage = (e) => {
|
||||||
|
const li = document.createElement("li"); li.textContent = e.data; log.prepend(li);
|
||||||
|
};
|
||||||
|
</script>`;
|
||||||
|
|
||||||
|
// The plugin's dynamic handler. Paths arrive prefix-stripped (the console proxy removed
|
||||||
|
// `/plugin-ui/demo-ui`), so we match `/` and `/events` directly.
|
||||||
|
const handle = (req: Request): Response | undefined => {
|
||||||
|
const { pathname } = new URL(req.url);
|
||||||
|
if (pathname === "/events") {
|
||||||
|
// A ticking SSE stream — the thing the proxy must forward unbuffered.
|
||||||
|
let n = 0;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
controller.enqueue(
|
||||||
|
enc.encode(`data: tick ${++n} @ ${new Date().toISOString()}\n\n`),
|
||||||
|
);
|
||||||
|
}, 1000);
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body, {
|
||||||
|
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pathname === "/" || pathname === "/index.html") {
|
||||||
|
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
|
||||||
|
}
|
||||||
|
return undefined; // fall through → 404
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugin = definePlugin({
|
||||||
|
name: "demo-ui",
|
||||||
|
main: async (pf) => {
|
||||||
|
const ui = await servePluginUi(pf, {
|
||||||
|
id: "demo-ui",
|
||||||
|
title: "Demo UI",
|
||||||
|
icon: "puzzle",
|
||||||
|
fetch: handle,
|
||||||
|
});
|
||||||
|
console.log(`[demo-ui] serving on ${ui.url} — open the console's "Demo UI" nav entry`);
|
||||||
|
// Run until asked to stop, then deregister cleanly (abrupt kills fall back to lease expiry).
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
process.once("SIGINT", resolve);
|
||||||
|
process.once("SIGTERM", resolve);
|
||||||
|
});
|
||||||
|
await ui.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
|
||||||
|
// Allow a direct `bun examples/plugin-ui.ts` run outside the managed runner.
|
||||||
|
if (import.meta.main) {
|
||||||
|
const pf = await connect();
|
||||||
|
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
|
||||||
|
pf.close();
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -30,6 +30,11 @@ import {
|
|||||||
export type { HostApi } from "./api.js";
|
export type { HostApi } from "./api.js";
|
||||||
export { HttpStatusError } from "./core.js";
|
export { HttpStatusError } from "./core.js";
|
||||||
export type { ConnectOptions } from "./config.js";
|
export type { ConnectOptions } from "./config.js";
|
||||||
|
export {
|
||||||
|
type PluginUiHandle,
|
||||||
|
type PluginUiOptions,
|
||||||
|
servePluginUi,
|
||||||
|
} from "./ui.js";
|
||||||
export type {
|
export type {
|
||||||
ClientRef,
|
ClientRef,
|
||||||
DeviceRef,
|
DeviceRef,
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
// `servePluginUi` (plugin-ui-surface design §4) — the whole plugin side of a console-hosted UI in
|
||||||
|
// one call. A plugin serves its UI on a **loopback ephemeral port** behind a **per-boot secret**,
|
||||||
|
// registers `{title, ui:{port, secret, icon}}` with the host, and renews the lease on a timer; the
|
||||||
|
// web console reverse-proxies to it and grows a nav entry. The plugin author writes zero human auth,
|
||||||
|
// discovery, or TLS — all of that lives here.
|
||||||
|
//
|
||||||
|
// import { definePlugin, servePluginUi } from "@punktfunk/host";
|
||||||
|
//
|
||||||
|
// export default definePlugin({
|
||||||
|
// name: "rom-manager",
|
||||||
|
// main: async (pf) => {
|
||||||
|
// const ui = await servePluginUi(pf, {
|
||||||
|
// id: "rom-manager", title: "ROM Manager", icon: "gamepad-2",
|
||||||
|
// staticDir: new URL("../dist/ui", import.meta.url), // built SPA
|
||||||
|
// fetch: (req) => appRouter(req), // plugin-local REST/SSE
|
||||||
|
// });
|
||||||
|
// try { await runEngineForever(); } finally { await ui.close(); }
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// Design notes:
|
||||||
|
// - **Runtime**: Bun (the scripting runner IS bun; a `node:http` lane is deferred — design Q1).
|
||||||
|
// - **Registration uses `pf.request`, not `pf.api.*`** (design D7): under the packaged runner the
|
||||||
|
// facade is built by the runner's *bundled* SDK copy, whose generated client may predate the
|
||||||
|
// `/plugins` endpoints; the untyped request seam has existed since 0.1.0 and is skew-proof.
|
||||||
|
// - **The host only ever dials 127.0.0.1:<port>** — we register a port, never an address (D5).
|
||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { Punktfunk } from "./index.js";
|
||||||
|
|
||||||
|
/** How often the lease is renewed (host TTL is 90 s — two missed ticks of slack). */
|
||||||
|
const DEFAULT_RENEW_MS = 30_000;
|
||||||
|
|
||||||
|
export interface PluginUiOptions {
|
||||||
|
/**
|
||||||
|
* The plugin's registered id — its `definePlugin` name (`[a-z][a-z0-9-]*`). The console nav
|
||||||
|
* entry and the proxy path `/plugin-ui/<id>/**` key on this.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/** Human-readable title for the console nav entry. */
|
||||||
|
title: string;
|
||||||
|
/** Optional plugin version (informational, shown in the console page header). */
|
||||||
|
version?: string;
|
||||||
|
/** Optional lucide icon name for the nav entry (`[a-z0-9-]`, e.g. `"gamepad-2"`). */
|
||||||
|
icon?: string;
|
||||||
|
/**
|
||||||
|
* Directory of the built SPA. Requests are served from here first (with an `index.html` SPA
|
||||||
|
* fallback for navigations); a static miss falls through to [`fetch`]. Accepts a filesystem
|
||||||
|
* path or a `file:` URL (`new URL("../dist/ui", import.meta.url)`).
|
||||||
|
*/
|
||||||
|
staticDir?: string | URL;
|
||||||
|
/**
|
||||||
|
* The plugin's own dynamic handler (REST, SSE) — tried after a static miss. Paths arrive
|
||||||
|
* **prefix-stripped** (the console proxy has already removed `/plugin-ui/<id>`), so this sees
|
||||||
|
* `/`, `/api/scan`, … The original public prefix is on the `X-Forwarded-Prefix` header if you
|
||||||
|
* need absolute self-URLs. Return `undefined` to fall through to the SPA fallback.
|
||||||
|
*/
|
||||||
|
fetch?: (req: Request) => Response | Promise<Response | undefined> | undefined;
|
||||||
|
/** Advanced: lease-renewal cadence in ms (default 30 000). Mainly for tests. */
|
||||||
|
renewIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginUiHandle {
|
||||||
|
/** The loopback port the UI is bound to. */
|
||||||
|
readonly port: number;
|
||||||
|
/** `http://127.0.0.1:<port>` — the base the console proxy dials. */
|
||||||
|
readonly url: string;
|
||||||
|
/** Deregister and stop the server (best-effort DELETE, then force-close). */
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = (m: string) => console.warn(`[punktfunk] servePluginUi: ${m}`);
|
||||||
|
|
||||||
|
/** A fresh per-boot secret: 32 random bytes as base64url (43 chars, `[A-Za-z0-9_-]`). */
|
||||||
|
const mintSecret = (): string => {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Buffer.from(bytes).toString("base64url");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a request path to an absolute file inside `root`, or `null` if it escapes (traversal). */
|
||||||
|
const staticFile = (root: string, pathname: string): string | null => {
|
||||||
|
let rel: string;
|
||||||
|
try {
|
||||||
|
rel = decodeURIComponent(pathname);
|
||||||
|
} catch {
|
||||||
|
return null; // malformed %-encoding
|
||||||
|
}
|
||||||
|
if (rel.endsWith("/")) rel += "index.html";
|
||||||
|
if (!rel.startsWith("/")) rel = `/${rel}`;
|
||||||
|
const abs = path.resolve(root, `.${rel}`);
|
||||||
|
const rootAbs = path.resolve(root);
|
||||||
|
if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null;
|
||||||
|
return abs;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a plugin UI and register it with the host. Returns once the server is listening and the
|
||||||
|
* first registration attempt has been made (a failed initial register is warned, not thrown — the
|
||||||
|
* renewal loop keeps trying, so a momentarily-unreachable host doesn't take the plugin down).
|
||||||
|
*/
|
||||||
|
export const servePluginUi = async (
|
||||||
|
pf: Punktfunk,
|
||||||
|
opts: PluginUiOptions,
|
||||||
|
): Promise<PluginUiHandle> => {
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(opts.id)) {
|
||||||
|
throw new Error(
|
||||||
|
`servePluginUi: id "${opts.id}" must be kebab-case ([a-z][a-z0-9-]*)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof (globalThis as Record<string, unknown>).Bun === "undefined") {
|
||||||
|
throw new Error(
|
||||||
|
"servePluginUi requires the Bun runtime (the scripting runner is bun); a Node lane is not yet available",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = opts.staticDir
|
||||||
|
? typeof opts.staticDir === "string"
|
||||||
|
? opts.staticDir
|
||||||
|
: fileURLToPath(opts.staticDir)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// One per-boot secret; the console proxy must present it (as a bearer) on every request. Compared
|
||||||
|
// constant-time against its SHA-256 (mirrors the host's `token_eq`), so no length/content timing.
|
||||||
|
const secret = mintSecret();
|
||||||
|
const secretHash = createHash("sha256").update(secret).digest();
|
||||||
|
const authorized = (req: Request): boolean => {
|
||||||
|
const header = req.headers.get("authorization");
|
||||||
|
const presented = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
||||||
|
if (presented === undefined) return false;
|
||||||
|
const presentedHash = createHash("sha256").update(presented).digest();
|
||||||
|
return timingSafeEqual(presentedHash, secretHash);
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: "127.0.0.1", // loopback only — nothing off-box can reach it
|
||||||
|
port: 0, // ephemeral: no port to configure or collide
|
||||||
|
async fetch(req) {
|
||||||
|
if (!authorized(req)) {
|
||||||
|
return new Response("unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const pathname = new URL(req.url).pathname;
|
||||||
|
// Built-in liveness — the console page probes this before mounting the iframe.
|
||||||
|
if (pathname === "/__health") {
|
||||||
|
return Response.json({ ok: true, id: opts.id, title: opts.title });
|
||||||
|
}
|
||||||
|
// 1) static asset
|
||||||
|
if (root) {
|
||||||
|
const file = staticFile(root, pathname);
|
||||||
|
if (file) {
|
||||||
|
const bf = Bun.file(file);
|
||||||
|
if (await bf.exists()) return new Response(bf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) the plugin's dynamic handler
|
||||||
|
if (opts.fetch) {
|
||||||
|
const res = await opts.fetch(req);
|
||||||
|
if (res) return res;
|
||||||
|
}
|
||||||
|
// 3) SPA fallback: a navigation that matched no asset gets index.html
|
||||||
|
if (
|
||||||
|
root &&
|
||||||
|
req.method === "GET" &&
|
||||||
|
(req.headers.get("accept") ?? "").includes("text/html")
|
||||||
|
) {
|
||||||
|
const index = Bun.file(path.join(root, "index.html"));
|
||||||
|
if (await index.exists()) return new Response(index);
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = server.port;
|
||||||
|
if (port == null) throw new Error("Bun.serve did not report a bound port");
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const body = {
|
||||||
|
title: opts.title,
|
||||||
|
...(opts.version !== undefined ? { version: opts.version } : {}),
|
||||||
|
ui: {
|
||||||
|
port,
|
||||||
|
secret,
|
||||||
|
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = () => pf.request("PUT", `/plugins/${opts.id}`, body);
|
||||||
|
// Best-effort initial register: warn but keep the server up if the host is momentarily away.
|
||||||
|
await register().catch((e) => warn(`initial registration failed: ${e}`));
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
register().catch((e) => warn(`lease renewal failed: ${e}`));
|
||||||
|
}, opts.renewIntervalMs ?? DEFAULT_RENEW_MS);
|
||||||
|
// Don't let the renewal timer alone keep the process alive — the plugin's main loop owns lifetime.
|
||||||
|
(timer as { unref?: () => void }).unref?.();
|
||||||
|
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
async close() {
|
||||||
|
clearInterval(timer);
|
||||||
|
// Deregister promptly so the nav entry drops without waiting for the lease to expire.
|
||||||
|
await pf.request("DELETE", `/plugins/${opts.id}`).catch(() => {});
|
||||||
|
server.stop(true); // force-close (SSE/long-poll connections included)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// `servePluginUi` end to end: it registers with the host (secret and all), serves static + dynamic
|
||||||
|
// + SPA-fallback behind the per-boot secret, renews the lease, and deregisters on close. The mock
|
||||||
|
// host records what the helper PUTs/DELETEs so we can assert the registration shape.
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { connect } from "../src/index.js";
|
||||||
|
import { servePluginUi } from "../src/ui.js";
|
||||||
|
|
||||||
|
const TOKEN = "test-token";
|
||||||
|
|
||||||
|
interface Registration {
|
||||||
|
id: string;
|
||||||
|
body: { title: string; version?: string; ui?: { port: number; secret: string; icon?: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mock management host that captures plugin registry writes.
|
||||||
|
const registrations: Registration[] = [];
|
||||||
|
const deletes: string[] = [];
|
||||||
|
|
||||||
|
const host = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) {
|
||||||
|
return Response.json({ error: "invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/v1/host") return Response.json({ hostname: "mock" });
|
||||||
|
const m = url.pathname.match(/^\/api\/v1\/plugins\/([a-z][a-z0-9-]*)$/);
|
||||||
|
if (m) {
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
registrations.push({ id: m[1], body: await req.json() });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
deletes.push(m[1]);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostUrl = `http://127.0.0.1:${host.port}`;
|
||||||
|
|
||||||
|
// A built SPA on disk: an index and one asset.
|
||||||
|
let staticDir: string;
|
||||||
|
beforeAll(() => {
|
||||||
|
staticDir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-ui-"));
|
||||||
|
fs.writeFileSync(path.join(staticDir, "index.html"), "INDEX");
|
||||||
|
fs.writeFileSync(path.join(staticDir, "app.js"), "ASSET");
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
host.stop(true);
|
||||||
|
fs.rmSync(staticDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET the plugin's own server with the captured secret. */
|
||||||
|
const authed = (base: string, p: string, secret: string, init?: RequestInit) =>
|
||||||
|
fetch(`${base}${p}`, { ...init, headers: { authorization: `Bearer ${secret}`, ...init?.headers } });
|
||||||
|
|
||||||
|
describe("servePluginUi", () => {
|
||||||
|
test("registers, serves, renews, and deregisters", async () => {
|
||||||
|
const pf = await connect({ url: hostUrl, token: TOKEN });
|
||||||
|
const ui = await servePluginUi(pf, {
|
||||||
|
id: "demo",
|
||||||
|
title: "Demo",
|
||||||
|
version: "9.9.9",
|
||||||
|
icon: "puzzle",
|
||||||
|
staticDir,
|
||||||
|
fetch: (req) =>
|
||||||
|
new URL(req.url).pathname === "/api/ping"
|
||||||
|
? Response.json({ pong: true })
|
||||||
|
: undefined,
|
||||||
|
renewIntervalMs: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- registration shape (the secret rides along; the port matches the bound server) ---
|
||||||
|
const reg = registrations.find((r) => r.id === "demo");
|
||||||
|
expect(reg).toBeDefined();
|
||||||
|
expect(reg?.body.title).toBe("Demo");
|
||||||
|
expect(reg?.body.version).toBe("9.9.9");
|
||||||
|
expect(reg?.body.ui?.port).toBe(ui.port);
|
||||||
|
expect(reg?.body.ui?.icon).toBe("puzzle");
|
||||||
|
const secret = reg?.body.ui?.secret ?? "";
|
||||||
|
expect(secret).toMatch(/^[A-Za-z0-9_-]{43}$/); // 32 random bytes, base64url
|
||||||
|
|
||||||
|
// --- the secret is mandatory on every request ---
|
||||||
|
expect((await fetch(`${ui.url}/__health`)).status).toBe(401);
|
||||||
|
const health = await authed(ui.url, "/__health", secret);
|
||||||
|
expect(health.status).toBe(200);
|
||||||
|
expect(await health.json()).toEqual({ ok: true, id: "demo", title: "Demo" });
|
||||||
|
|
||||||
|
// --- static assets, then SPA fallback for a navigation that matched no file ---
|
||||||
|
expect(await (await authed(ui.url, "/", secret, { headers: { accept: "text/html" } })).text()).toBe("INDEX");
|
||||||
|
expect(await (await authed(ui.url, "/app.js", secret)).text()).toBe("ASSET");
|
||||||
|
const spa = await authed(ui.url, "/some/client/route", secret, { headers: { accept: "text/html" } });
|
||||||
|
expect(await spa.text()).toBe("INDEX"); // SPA fallback
|
||||||
|
// A missing NON-navigation asset is a real 404, not the index.
|
||||||
|
expect((await authed(ui.url, "/missing.js", secret)).status).toBe(404);
|
||||||
|
|
||||||
|
// --- the plugin's dynamic handler wins for its own routes ---
|
||||||
|
expect(await (await authed(ui.url, "/api/ping", secret)).json()).toEqual({ pong: true });
|
||||||
|
|
||||||
|
// --- lease renewal keeps PUTting on the interval ---
|
||||||
|
const before = registrations.filter((r) => r.id === "demo").length;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
expect(registrations.filter((r) => r.id === "demo").length).toBeGreaterThan(before);
|
||||||
|
|
||||||
|
// --- close deregisters and stops the server ---
|
||||||
|
await ui.close();
|
||||||
|
expect(deletes).toContain("demo");
|
||||||
|
await expect(fetch(`${ui.url}/__health`)).rejects.toThrow(); // connection refused
|
||||||
|
pf.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a non-kebab id before binding anything", async () => {
|
||||||
|
const pf = await connect({ url: hostUrl, token: TOKEN });
|
||||||
|
await expect(servePluginUi(pf, { id: "Bad_Id", title: "x" })).rejects.toThrow(/kebab/);
|
||||||
|
pf.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
"nav_clients": "Gekoppelte Geräte",
|
"nav_clients": "Gekoppelte Geräte",
|
||||||
"nav_pairing": "Kopplung",
|
"nav_pairing": "Kopplung",
|
||||||
"nav_library": "Bibliothek",
|
"nav_library": "Bibliothek",
|
||||||
|
"nav_plugins": "Plugins",
|
||||||
|
"plugin_offline_title": "Dieses Plugin läuft nicht",
|
||||||
|
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
|
||||||
|
"plugin_retry": "Erneut versuchen",
|
||||||
|
"plugin_open_new_tab": "In neuem Tab öffnen",
|
||||||
"nav_settings": "Einstellungen",
|
"nav_settings": "Einstellungen",
|
||||||
"nav_more": "Mehr",
|
"nav_more": "Mehr",
|
||||||
"status_title": "Live-Status",
|
"status_title": "Live-Status",
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
"nav_library": "Library",
|
"nav_library": "Library",
|
||||||
"nav_settings": "Settings",
|
"nav_settings": "Settings",
|
||||||
"nav_more": "More",
|
"nav_more": "More",
|
||||||
|
"nav_plugins": "Plugins",
|
||||||
|
"plugin_offline_title": "This plugin isn't running",
|
||||||
|
"plugin_offline_hint": "Start the scripting runner, then retry.",
|
||||||
|
"plugin_retry": "Retry",
|
||||||
|
"plugin_open_new_tab": "Open in new tab",
|
||||||
"status_title": "Live status",
|
"status_title": "Live status",
|
||||||
"status_video": "Video",
|
"status_video": "Video",
|
||||||
"status_audio": "Audio",
|
"status_audio": "Audio",
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ import { isLoopbackUrl, mgmtToken, mgmtUrl } from "../../util/auth";
|
|||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { pathname, search } = getRequestURL(event);
|
const { pathname, search } = getRequestURL(event);
|
||||||
|
// A plugin UI's proxy credential (its per-boot secret) is fetched server-side by the
|
||||||
|
// /plugin-ui proxy and must NEVER reach a browser — deny it on the generic passthrough so a
|
||||||
|
// session-authed page can't read it (plugin-ui-surface §5, D6). The secret-free list at
|
||||||
|
// /api/v1/plugins is fine; only the {id}/ui-credential leaf is blocked.
|
||||||
|
if (/^\/api\/v1\/plugins\/[^/]+\/ui-credential\/?$/.test(pathname)) {
|
||||||
|
setResponseStatus(event, 403);
|
||||||
|
return {
|
||||||
|
error: "plugin UI credentials are not accessible from the browser",
|
||||||
|
};
|
||||||
|
}
|
||||||
const base = mgmtUrl();
|
const base = mgmtUrl();
|
||||||
const target = `${base}${pathname}${search}`;
|
const target = `${base}${pathname}${search}`;
|
||||||
const token = mgmtToken();
|
const token = mgmtToken();
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// /plugin-ui/<id>/** → a plugin's loopback UI server (plugin-ui-surface §5). By the time we get
|
||||||
|
// here the gate (middleware/auth.ts) has confirmed a session — a plugin UI is reachable only by the
|
||||||
|
// logged-in operator, on the console's own origin, with no separate password. We look up the
|
||||||
|
// plugin's `{port, secret}` server-side, inject the secret as a bearer, strip the browser's cookie,
|
||||||
|
// and stream the response through (SSE included). The plugin only ever gets dialed on 127.0.0.1.
|
||||||
|
//
|
||||||
|
// This route runs in the built Bun/Nitro server. In `vite dev` a small middleware in vite.config.ts
|
||||||
|
// handles `/plugin-ui` instead (it intercepts before this route, like the /api dev proxy).
|
||||||
|
import {
|
||||||
|
defineEventHandler,
|
||||||
|
getProxyRequestHeaders,
|
||||||
|
getRequestURL,
|
||||||
|
readRawBody,
|
||||||
|
sendWebResponse,
|
||||||
|
setResponseStatus,
|
||||||
|
} from "h3";
|
||||||
|
import {
|
||||||
|
bustCredential,
|
||||||
|
fetchUiCredential,
|
||||||
|
PLUGIN_ID_RE,
|
||||||
|
} from "../../util/pluginProxy";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { pathname, search } = getRequestURL(event);
|
||||||
|
// /plugin-ui/<id>/<rest…>
|
||||||
|
const m = pathname.match(/^\/plugin-ui\/([^/]+)(\/.*)?$/);
|
||||||
|
const id = m?.[1];
|
||||||
|
if (!id || !PLUGIN_ID_RE.test(id)) {
|
||||||
|
setResponseStatus(event, 404);
|
||||||
|
return { error: "not a valid plugin-ui path" };
|
||||||
|
}
|
||||||
|
const rest = m?.[2] ?? "/";
|
||||||
|
const prefix = `/plugin-ui/${id}`;
|
||||||
|
|
||||||
|
// Forwardable request headers (h3 strips hop-by-hop + host); we set our own auth and drop the
|
||||||
|
// session cookie so plugin code never sees it.
|
||||||
|
const headers = getProxyRequestHeaders(event) as Record<string, string>;
|
||||||
|
delete headers.cookie;
|
||||||
|
delete headers.authorization;
|
||||||
|
headers["x-forwarded-prefix"] = prefix;
|
||||||
|
const method = event.method;
|
||||||
|
const body =
|
||||||
|
method === "GET" || method === "HEAD"
|
||||||
|
? undefined
|
||||||
|
: ((await readRawBody(event, false)) as Uint8Array | undefined);
|
||||||
|
|
||||||
|
// One proxied attempt; `null` means the plugin is unreachable (unregistered, or its port died).
|
||||||
|
const attempt = async (bustCache: boolean): Promise<Response | null> => {
|
||||||
|
const cred = await fetchUiCredential(id, { bustCache });
|
||||||
|
if (!cred) return null;
|
||||||
|
const target = `http://127.0.0.1:${cred.port}${rest}${search}`;
|
||||||
|
try {
|
||||||
|
return await fetch(target, {
|
||||||
|
method,
|
||||||
|
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
|
||||||
|
body: body as BodyInit | undefined,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The port is dead (plugin crashed/restarted on a new port): drop the stale credential so
|
||||||
|
// the next request re-resolves it.
|
||||||
|
bustCredential(id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = await attempt(false);
|
||||||
|
// Stale secret after a plugin restart (S7): the plugin rejects our cached secret — re-fetch once.
|
||||||
|
if (resp?.status === 401) {
|
||||||
|
const retry = await attempt(true);
|
||||||
|
if (retry) resp = retry;
|
||||||
|
}
|
||||||
|
if (!resp) {
|
||||||
|
setResponseStatus(event, 502);
|
||||||
|
return { error: `plugin "${id}" is not running` };
|
||||||
|
}
|
||||||
|
return sendWebResponse(event, resp);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Server-side helper for the plugin-UI reverse proxy (plugin-ui-surface §5). The console proxies
|
||||||
|
// `/plugin-ui/<id>/**` to a plugin's loopback UI server, injecting the plugin's per-boot secret —
|
||||||
|
// which it fetches here, from the management API, **server-side only** (the secret never reaches the
|
||||||
|
// browser; the BFF additionally denylists the credential endpoint from the generic passthrough).
|
||||||
|
//
|
||||||
|
// The credential is cached briefly so a burst of iframe asset requests doesn't hammer the host. On a
|
||||||
|
// 401 from the plugin (its secret rotated on restart within the cache window) the proxy busts this
|
||||||
|
// cache and re-fetches once — see the route.
|
||||||
|
import { isLoopbackUrl, mgmtToken, mgmtUrl } from "./auth";
|
||||||
|
|
||||||
|
/** A plugin id — its `definePlugin` name; the same shape the host validates. */
|
||||||
|
export const PLUGIN_ID_RE = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|
||||||
|
/** The proxy credential for a plugin's loopback UI. */
|
||||||
|
export interface UiCredential {
|
||||||
|
port: number;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 15_000;
|
||||||
|
const cache = new Map<string, { cred: UiCredential | null; at: number }>();
|
||||||
|
|
||||||
|
/** Drop a cached credential (called when a plugin's secret proved stale). */
|
||||||
|
export function bustCredential(id: string): void {
|
||||||
|
cache.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch `{port, secret}` for a plugin's UI from the management API (bearer, loopback). Returns
|
||||||
|
* `null` when the plugin isn't registered / has no UI (a 404). Results are cached for {@link TTL_MS};
|
||||||
|
* pass `bustCache` to force a fresh read (the stale-secret retry). Throws only on a missing mgmt
|
||||||
|
* token (a deploy misconfig) — a transient upstream error resolves to `null` (treated as offline)
|
||||||
|
* and is not cached.
|
||||||
|
*/
|
||||||
|
export async function fetchUiCredential(
|
||||||
|
id: string,
|
||||||
|
opts?: { bustCache?: boolean },
|
||||||
|
): Promise<UiCredential | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!opts?.bustCache) {
|
||||||
|
const hit = cache.get(id);
|
||||||
|
if (hit && now - hit.at < TTL_MS) return hit.cred;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = mgmtUrl();
|
||||||
|
const token = mgmtToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"management token not configured (PUNKTFUNK_MGMT_TOKEN / ~/.config/punktfunk/mgmt-token)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The host serves the credential over HTTPS with its self-signed loopback cert; relax
|
||||||
|
// verification for that one loopback hop only (the same scoping the /api BFF uses).
|
||||||
|
const fetchOptions = isLoopbackUrl(base)
|
||||||
|
? ({ tls: { rejectUnauthorized: false } } as unknown as RequestInit)
|
||||||
|
: undefined;
|
||||||
|
const resp = await fetch(`${base}/api/v1/plugins/${id}/ui-credential`, {
|
||||||
|
...fetchOptions,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const cred = (await resp.json()) as UiCredential;
|
||||||
|
cache.set(id, { cred, at: now });
|
||||||
|
return cred;
|
||||||
|
}
|
||||||
|
if (resp.status === 404) {
|
||||||
|
// Definitively not running / no UI — cache the negative so a dead iframe doesn't spin.
|
||||||
|
cache.set(id, { cred: null, at: now });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Transient (401/5xx): don't cache, let the next request retry.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// The plugin directory the console reads to grow its nav (plugin-ui-surface §5). This is a
|
||||||
|
// hand-written client (not orval-generated) so the nav works without regenerating the API client
|
||||||
|
// for the new endpoints; it rides the same `/api` BFF path as every other call, so the bearer token
|
||||||
|
// is injected server-side and the browser only ever sends its session cookie.
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Blocks,
|
||||||
|
Boxes,
|
||||||
|
Clapperboard,
|
||||||
|
Database,
|
||||||
|
FolderCog,
|
||||||
|
Gamepad2,
|
||||||
|
Home,
|
||||||
|
type LucideIcon,
|
||||||
|
Plug,
|
||||||
|
Puzzle,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { apiFetch } from "@/api/fetcher";
|
||||||
|
|
||||||
|
export interface PluginUiSummary {
|
||||||
|
port: number;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
version?: string;
|
||||||
|
/** Present iff the plugin serves a UI (and thus gets a nav entry). */
|
||||||
|
ui?: PluginUiSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat
|
||||||
|
// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle.
|
||||||
|
const ICONS: Record<string, LucideIcon> = {
|
||||||
|
"gamepad-2": Gamepad2,
|
||||||
|
puzzle: Puzzle,
|
||||||
|
wrench: Wrench,
|
||||||
|
database: Database,
|
||||||
|
home: Home,
|
||||||
|
blocks: Blocks,
|
||||||
|
boxes: Boxes,
|
||||||
|
plug: Plug,
|
||||||
|
"folder-cog": FolderCog,
|
||||||
|
clapperboard: Clapperboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a registered icon name to a component (Puzzle fallback). */
|
||||||
|
export const pluginIcon = (name?: string): LucideIcon =>
|
||||||
|
(name ? ICONS[name] : undefined) ?? Puzzle;
|
||||||
|
|
||||||
|
/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */
|
||||||
|
export function usePlugins() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["plugins"],
|
||||||
|
queryFn: () => apiFetch<PluginSummary[]>("/api/v1/plugins"),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the plugins that surface a UI — the ones that get a nav entry. */
|
||||||
|
export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] =>
|
||||||
|
(list ?? []).filter((p) => p.ui);
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion, stagger } from "motion/react";
|
import { motion, stagger } from "motion/react";
|
||||||
import { type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
|
import { pluginIcon, uiPlugins, usePlugins } from "@/api/plugins";
|
||||||
import { BrandMark } from "@/components/brand-mark";
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
import { Wordmark } from "@/components/wordmark";
|
import { Wordmark } from "@/components/wordmark";
|
||||||
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
import { changeLocale, type Locale, locales, useLocale } from "@/lib/i18n";
|
||||||
@@ -91,6 +92,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</MLink>
|
</MLink>
|
||||||
))}
|
))}
|
||||||
</motion.nav>
|
</motion.nav>
|
||||||
|
<PluginNavSection />
|
||||||
<div className="mt-auto pt-4">
|
<div className="mt-auto pt-4">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
@@ -119,12 +121,52 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Desktop sidebar: the dynamic "Plugins" group, fed by the plugin directory. Renders nothing until
|
||||||
|
* at least one plugin surfaces a UI — a host with no plugins sees zero extra chrome. */
|
||||||
|
function PluginNavSection() {
|
||||||
|
const { data } = usePlugins();
|
||||||
|
const plugins = uiPlugins(data);
|
||||||
|
if (plugins.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-6 flex flex-col gap-1">
|
||||||
|
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||||
|
{m.nav_plugins()}
|
||||||
|
</p>
|
||||||
|
{plugins.map((p) => {
|
||||||
|
const Icon = pluginIcon(p.ui?.icon);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
to="/plugins/$pluginId/$"
|
||||||
|
params={{ pluginId: p.id, _splat: "" }}
|
||||||
|
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
activeProps={{
|
||||||
|
className: "bg-primary/15 text-foreground font-medium",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
|
||||||
|
/>
|
||||||
|
<Icon className="relative size-4" />
|
||||||
|
<span className="relative truncate">{p.title}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */
|
/** Mobile bottom navigation (< sm): four pinned tabs + a "More" tab whose sheet holds the rest. */
|
||||||
function MobileNav() {
|
function MobileNav() {
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
// Highlight "More" when the current route lives in the overflow.
|
const { data } = usePlugins();
|
||||||
const overflowActive = MOBILE_OVERFLOW.some(
|
const plugins = uiPlugins(data);
|
||||||
|
// Highlight "More" when the current route lives in the overflow — plugins included.
|
||||||
|
const overflowActive =
|
||||||
|
pathname.startsWith("/plugins/") ||
|
||||||
|
MOBILE_OVERFLOW.some(
|
||||||
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`),
|
(n) => pathname === n.to || pathname.startsWith(`${n.to}/`),
|
||||||
);
|
);
|
||||||
// Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon
|
// Fixed two-line-tall label box so a 1- or 2-line label (labels vary by locale) keeps every icon
|
||||||
@@ -164,6 +206,22 @@ function MobileNav() {
|
|||||||
<span className={lbl}>{label()}</span>
|
<span className={lbl}>{label()}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{plugins.map((p) => {
|
||||||
|
const Icon = pluginIcon(p.ui?.icon);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
to="/plugins/$pluginId/$"
|
||||||
|
params={{ pluginId: p.id, _splat: "" }}
|
||||||
|
onClick={() => setMoreOpen(false)}
|
||||||
|
className={cn(tab, "rounded-md")}
|
||||||
|
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||||
|
>
|
||||||
|
<Icon className="size-5 shrink-0" />
|
||||||
|
<span className={lbl}>{p.title}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { SectionPlugin } from "@/sections/Plugins";
|
||||||
|
|
||||||
|
// A plugin's console-hosted UI (plugin-ui-surface). The `$` splat carries the plugin's own path so
|
||||||
|
// deep links survive a reload; the section maps it to the iframe src and keeps it in sync.
|
||||||
|
export const Route = createFileRoute("/plugins/$pluginId/$")({
|
||||||
|
component: SectionPlugin,
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// A plugin's UI, embedded in the console (plugin-ui-surface §5). We probe the plugin's liveness
|
||||||
|
// first and only mount the iframe when it answers — otherwise the iframe would show the proxy's raw
|
||||||
|
// 502. The iframe is same-origin (proxied through /plugin-ui), so the plugin can talk to its own
|
||||||
|
// loopback REST with the operator's session and, optionally, keep the address bar in sync by posting
|
||||||
|
// `{ type: "pf-ui:navigate", path }` to the parent.
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||||
|
import { type FC, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { pluginIcon, usePlugins } from "@/api/plugins";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
const route = getRouteApi("/plugins/$pluginId/$");
|
||||||
|
|
||||||
|
export const SectionPlugin: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
const { pluginId, _splat } = route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
// Header metadata (title/version/icon) from the directory; falls back to the id.
|
||||||
|
const { data: plugins } = usePlugins();
|
||||||
|
const meta = plugins?.find((p) => p.id === pluginId);
|
||||||
|
const Icon = pluginIcon(meta?.ui?.icon);
|
||||||
|
const title = meta?.title ?? pluginId;
|
||||||
|
|
||||||
|
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
|
||||||
|
// offline card (the manual Retry re-probes).
|
||||||
|
const health = useQuery({
|
||||||
|
queryKey: ["plugin-health", pluginId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await fetch(`/plugin-ui/${pluginId}/__health`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`health ${r.status}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
refetchInterval: (q) => (q.state.status === "error" ? false : 20_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The iframe src is fixed at the initial deep-link path; the plugin's own in-app navigation drives
|
||||||
|
// the console URL via postMessage (below), never the src — so there's no reload loop.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally pinned to the initial path
|
||||||
|
const initialSrc = useMemo(
|
||||||
|
() => `/plugin-ui/${pluginId}/${_splat ?? ""}`,
|
||||||
|
[pluginId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the console address bar in sync with the plugin's internal routing.
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (e: MessageEvent) => {
|
||||||
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
||||||
|
const data = e.data as { type?: string; path?: string };
|
||||||
|
if (data?.type === "pf-ui:navigate" && typeof data.path === "string") {
|
||||||
|
navigate({
|
||||||
|
to: "/plugins/$pluginId/$",
|
||||||
|
params: { pluginId, _splat: data.path.replace(/^\//, "") },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
}, [pluginId, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100dvh-7rem)] min-h-[480px] flex-col gap-3 sm:h-[calc(100dvh-5rem)]">
|
||||||
|
{/* Header strip: identity + open-in-new-tab (the plugin stands alone full-window too). */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">{title}</h1>
|
||||||
|
{meta?.version && (
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||||
|
v{meta.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`/plugin-ui/${pluginId}/`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
{m.plugin_open_new_tab()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{health.isError ? (
|
||||||
|
<OfflineCard title={title} onRetry={() => health.refetch()} />
|
||||||
|
) : health.isSuccess ? (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={initialSrc}
|
||||||
|
title={title}
|
||||||
|
className="w-full flex-1 rounded-lg border bg-card"
|
||||||
|
// The plugin is operator-installed code on our own origin (no new trust boundary —
|
||||||
|
// plugin-ui-surface §7.4); allow it to run scripts, forms, popups, and full-window.
|
||||||
|
sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-modals"
|
||||||
|
allow="fullscreen"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Probing: a calm shimmer rather than a flash of empty frame.
|
||||||
|
<div className="flex-1 animate-pulse rounded-lg border bg-card/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OfflineCard: FC<{ title: string; onRetry: () => void }> = ({
|
||||||
|
title,
|
||||||
|
onRetry,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed">
|
||||||
|
<div className="flex max-w-md flex-col items-center gap-3 p-8 text-center">
|
||||||
|
<h2 className="text-base font-semibold">{m.plugin_offline_title()}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{title}</span> —{" "}
|
||||||
|
{m.plugin_offline_hint()}
|
||||||
|
</p>
|
||||||
|
{/* The exact runner commands, so the operator can act without leaving the page. */}
|
||||||
|
<pre className="w-full overflow-x-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
|
||||||
|
<code>
|
||||||
|
systemctl --user status punktfunk-scripting{"\n"}
|
||||||
|
Get-ScheduledTask PunktfunkScripting{" # Windows"}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
{m.plugin_retry()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
+102
-1
@@ -1,10 +1,12 @@
|
|||||||
|
import * as nodeHttp from "node:http";
|
||||||
|
import * as nodeHttps from "node:https";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
|
import { nitroV2Plugin } from "@tanstack/nitro-v2-vite-plugin";
|
||||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
import viteReact from "@vitejs/plugin-react";
|
import viteReact from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
import viteTsConfigPaths from "vite-tsconfig-paths";
|
import viteTsConfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
|
// Absolute path to our Nitro server source (middleware + routes). Passed as a scanDir
|
||||||
@@ -16,6 +18,103 @@ const serverDir = fileURLToPath(new URL("./server", import.meta.url));
|
|||||||
// route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL.
|
// route-rule proxies it (below). Override the upstream with PUNKTFUNK_MGMT_URL.
|
||||||
const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
|
const MGMT_URL = process.env.PUNKTFUNK_MGMT_URL ?? "https://127.0.0.1:47990";
|
||||||
|
|
||||||
|
// Dev-only `/plugin-ui/<id>/**` reverse proxy — the vite-dev counterpart of the Bun/Nitro route
|
||||||
|
// (server/routes/plugin-ui/[...].ts), which can't run in dev because it uses Bun's `tls` fetch
|
||||||
|
// option. Same contract: look up the plugin's {port, secret} from the management API server-side,
|
||||||
|
// inject the secret, strip the cookie, dial 127.0.0.1 only, stream the response (SSE included).
|
||||||
|
// Needs PUNKTFUNK_MGMT_TOKEN in the dev environment (like talking to any token-required host).
|
||||||
|
function pluginUiDevProxy(): Plugin {
|
||||||
|
const fetchCred = (
|
||||||
|
id: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<{ port: number; secret: string } | null> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const u = new URL(`${MGMT_URL}/api/v1/plugins/${id}/ui-credential`);
|
||||||
|
const mod = u.protocol === "https:" ? nodeHttps : nodeHttp;
|
||||||
|
const r = mod.request(
|
||||||
|
u,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
rejectUnauthorized: false, // host's self-signed loopback cert
|
||||||
|
} as nodeHttps.RequestOptions,
|
||||||
|
(resp) => {
|
||||||
|
let data = "";
|
||||||
|
resp.on("data", (c) => {
|
||||||
|
data += c;
|
||||||
|
});
|
||||||
|
resp.on("end", () => {
|
||||||
|
if (resp.statusCode === 200) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
} else resolve(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.on("error", () => resolve(null));
|
||||||
|
r.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "punktfunk-plugin-ui-dev-proxy",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/plugin-ui", async (req, res) => {
|
||||||
|
const raw = req.url ?? "/"; // connect strips the /plugin-ui mount prefix
|
||||||
|
const m = raw.match(/^\/([a-z][a-z0-9-]*)(\/[^?]*)?(\?.*)?$/);
|
||||||
|
const id = m?.[1];
|
||||||
|
if (!id) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("bad plugin-ui path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rest = m?.[2] ?? "/";
|
||||||
|
const search = m?.[3] ?? "";
|
||||||
|
const token = process.env.PUNKTFUNK_MGMT_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.end("dev plugin-ui proxy: set PUNKTFUNK_MGMT_TOKEN");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cred = await fetchCred(id, token);
|
||||||
|
if (!cred) {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.end(`plugin "${id}" is not running`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = { ...req.headers } as Record<string, string | string[]>;
|
||||||
|
delete headers.host;
|
||||||
|
delete headers.cookie;
|
||||||
|
delete headers.authorization;
|
||||||
|
headers["x-forwarded-prefix"] = `/plugin-ui/${id}`;
|
||||||
|
const proxyReq = nodeHttp.request(
|
||||||
|
{
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: cred.port,
|
||||||
|
method: req.method,
|
||||||
|
path: rest + search,
|
||||||
|
headers: { ...headers, authorization: `Bearer ${cred.secret}` },
|
||||||
|
},
|
||||||
|
(pr) => {
|
||||||
|
res.statusCode = pr.statusCode ?? 502;
|
||||||
|
for (const [k, v] of Object.entries(pr.headers)) {
|
||||||
|
if (v !== undefined) res.setHeader(k, v);
|
||||||
|
}
|
||||||
|
pr.pipe(res); // stream (SSE included)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
proxyReq.on("error", () => {
|
||||||
|
res.statusCode = 502;
|
||||||
|
res.end("plugin unreachable");
|
||||||
|
});
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -24,6 +123,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// First, so it intercepts /plugin-ui before the SSR catch-all in dev.
|
||||||
|
pluginUiDevProxy(),
|
||||||
viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
|
viteTsConfigPaths({ projects: ["./tsconfig.json"] }),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
paraglideVitePlugin({
|
paraglideVitePlugin({
|
||||||
|
|||||||
Reference in New Issue
Block a user