Compare commits
@@ -22,25 +22,15 @@ jobs:
|
||||
screenshots:
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
# JDK 21 + SDK baked (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 17–21).
|
||||
# The tests are pure JVM (no NDK), but sharing android.yml's image means one image to
|
||||
# keep warm instead of a per-run setup-java + sdkmanager download pair.
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 17–21)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Android SDK
|
||||
# SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
|
||||
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
|
||||
# if the platform channel lacks it (same note as android.yml).
|
||||
- name: platform-tools + platform 36 + build-tools
|
||||
run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0"
|
||||
|
||||
- name: Cache (gradle)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -49,9 +39,10 @@ jobs:
|
||||
~/.gradle/wrapper
|
||||
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
|
||||
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
|
||||
# restore a key that can never hold the new one.
|
||||
key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: android-screenshots-
|
||||
# restore a key that can never hold the new one. Namespace shared with android.yml —
|
||||
# it is the same content; two keys just stored it twice in the central cache.
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
# Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps
|
||||
# the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
|
||||
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
|
||||
#
|
||||
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
|
||||
# distribution in-job). If android-actions/setup-android is not mirrored on this Gitea instance,
|
||||
# replace that step with a manual cmdline-tools download, or bake an `android-ci` image like
|
||||
# ci/rust-ci.Dockerfile. Emulator instrumentation tests are deferred until a KVM-capable runner
|
||||
# exists (they self-skip otherwise, like apple.yml's RemoteFirstLightTests).
|
||||
# Runs in the punktfunk-android-ci builder image (ci/android-ci.Dockerfile, content-keyed on
|
||||
# the LAN registry): JDK 21, the Android SDK/NDK/CMake pins, cargo-ndk and sccache are all
|
||||
# baked, so the multi-GB per-run Google downloads this job used to make are gone. Emulator
|
||||
# instrumentation tests are deferred until a KVM-capable runner exists (they self-skip
|
||||
# otherwise, like apple.yml's RemoteFirstLightTests).
|
||||
name: android
|
||||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||||
@@ -26,6 +26,9 @@ on:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/android/**'
|
||||
# The builder image is part of what this artifact is built from — an image
|
||||
# change must exercise its consumer.
|
||||
- 'ci/android-ci.Dockerfile'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
@@ -39,6 +42,9 @@ on:
|
||||
paths:
|
||||
- 'crates/**'
|
||||
- 'clients/android/**'
|
||||
# The builder image is part of what this artifact is built from — an image
|
||||
# change must exercise its consumer.
|
||||
- 'ci/android-ci.Dockerfile'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
@@ -46,66 +52,62 @@ on:
|
||||
- '.gitea/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
|
||||
# unbound). The NDK clang targets get their own key universes automatically (keys embed
|
||||
# compiler hash + target), so the three ABI builds share the bucket with everything else.
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||||
# cache, dev boxes keep incremental.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: 192.168.1.58:5010/punktfunk-android-ci:latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: JDK 21 (AGP 9.3 runs on JDK 17–21, not the host default)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
# Everything below the checkout used to be four download steps (JDK, SDK,
|
||||
# NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked
|
||||
# into the image now. This guard only re-asserts the Android targets so a
|
||||
# rust-toolchain.toml pin bump keeps working against an older image (:latest lags
|
||||
# one image rebuild, same bootstrap note as ci.yml's dep steps).
|
||||
- name: Rust Android targets (no-op unless the toolchain pin outran the image)
|
||||
run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
- name: Rust toolchain + Android targets (self-healing on a fresh runner)
|
||||
run: |
|
||||
if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal
|
||||
fi
|
||||
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
|
||||
- name: Android SDK
|
||||
# SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
|
||||
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
|
||||
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
# Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical
|
||||
# CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with
|
||||
# the rest of the fleet in the central cache. target/ is deliberately NOT cached
|
||||
# anymore — sccache covers recompilation without shipping multi-GB tars per run.
|
||||
- name: Cache (cargo registry)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
# Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain
|
||||
# drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred).
|
||||
# That download was the single flakiest piece of this job: the shared runner fleet drops
|
||||
# packets under parallel-job load and sdkmanager's streamed unzip turns a truncated
|
||||
# stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice).
|
||||
packages: platform-tools
|
||||
|
||||
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
|
||||
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
|
||||
# kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build.
|
||||
# Note: platforms;android-37 is sometimes missing from standard channels; AGP will
|
||||
# auto-download it if needed during the build.
|
||||
# retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the
|
||||
# helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed
|
||||
# attempt leaves no partial package behind, so a plain re-invoke is safe.
|
||||
run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1"
|
||||
path: |
|
||||
/usr/local/cargo/registry
|
||||
/usr/local/cargo/git
|
||||
key: cargo-home-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-home-
|
||||
|
||||
- name: Caches (cargo + gradle)
|
||||
- name: Cache (gradle)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
target
|
||||
# gradle-wrapper.properties is in the key on purpose — see android-screenshots.yml.
|
||||
key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: android-
|
||||
|
||||
- name: cargo-ndk
|
||||
run: command -v cargo-ndk >/dev/null || cargo install cargo-ndk
|
||||
# gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the
|
||||
# Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise
|
||||
# restore a key that can never hold the new one. Namespace shared with
|
||||
# android-screenshots.yml — same content, one copy in the central store.
|
||||
key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
- name: assembleDebug (cargo-ndk → jniLibs → APK)
|
||||
working-directory: clients/android
|
||||
|
||||
@@ -31,6 +31,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Publish the SIGNED stable update manifest — the moment every host's update check learns
|
||||
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
|
||||
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
|
||||
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
|
||||
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
|
||||
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
|
||||
- name: Publish the stable update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
case "$TAG" in
|
||||
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
|
||||
esac
|
||||
VER="${TAG#v}"
|
||||
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
|
||||
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
grep -qi "$SHA" /tmp/installer.sha256 || {
|
||||
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
|
||||
exit 1
|
||||
}
|
||||
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
- name: Post release announcement to Discord
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
@@ -44,6 +44,21 @@ on:
|
||||
- '.gitea/workflows/apple.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
|
||||
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
|
||||
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
|
||||
# side stays on DerivedData (sccache doesn't cache swiftc).
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
|
||||
# runner's persistent target/ disposable instead of precious.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
|
||||
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
|
||||
@@ -70,6 +85,18 @@ jobs:
|
||||
dirname "$RUSTUP" >> "$GITHUB_PATH"
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
|
||||
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
|
||||
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
|
||||
- name: sccache (self-healing install)
|
||||
run: |
|
||||
if ! command -v sccache >/dev/null; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
|
||||
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
|
||||
fi
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
sccache --version
|
||||
|
||||
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
|
||||
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
|
||||
# system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs).
|
||||
@@ -128,6 +155,18 @@ jobs:
|
||||
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
|
||||
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
|
||||
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
|
||||
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
|
||||
- name: sccache (self-healing install)
|
||||
run: |
|
||||
if ! command -v sccache >/dev/null; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
|
||||
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
|
||||
fi
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
sccache --version
|
||||
|
||||
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
|
||||
- name: CMake (for the vendored libopus audiopus_sys builds)
|
||||
run: |
|
||||
@@ -144,6 +183,20 @@ jobs:
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pin + prune DerivedData (same disease release.yml already cures)
|
||||
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
|
||||
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
|
||||
# honored by the script) so repeat runs are incremental, and GC anything a week old
|
||||
# in the default DerivedData root that no pin owns.
|
||||
run: |
|
||||
DD="$HOME/ci/derived-data/screenshots"
|
||||
mkdir -p "$DD"
|
||||
echo "PF_SHOT_DERIVED_DATA=$DD" >> "$GITHUB_ENV"
|
||||
if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then
|
||||
find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \
|
||||
-mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Build PunktfunkCore.xcframework (mac + iOS slices)
|
||||
run: BUILD_IOS=1 bash scripts/build-xcframework.sh
|
||||
|
||||
@@ -157,6 +210,10 @@ jobs:
|
||||
bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped"
|
||||
echo "Produced:"; ls -la screenshots || true
|
||||
|
||||
- name: Shut the Simulators down (leaked booted sims once piled up 846 deep)
|
||||
if: always()
|
||||
run: xcrun simctl shutdown all || true
|
||||
|
||||
- name: Upload screenshots (zip artifact)
|
||||
if: always()
|
||||
# v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+
|
||||
|
||||
@@ -53,27 +53,41 @@ on:
|
||||
env:
|
||||
REGISTRY: git.unom.io
|
||||
OWNER: unom
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io). NOTE: makepkg runs
|
||||
# behind `sudo -u builder env ...`, which strips ambient env — the makepkg step
|
||||
# re-exports these explicitly.
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
build-publish:
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: docker.io/library/archlinux:base-devel
|
||||
# Everything the two pacman steps below used to download (~1 GB/run) is baked in,
|
||||
# plus bun, sccache and node (ci/arch-ci.Dockerfile). The steps stay as --needed
|
||||
# no-op guards for the one push where :latest lags an image-content change.
|
||||
image: 192.168.1.58:5010/punktfunk-arch-ci:latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_HOME: /usr/local/cargo
|
||||
steps:
|
||||
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
|
||||
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
|
||||
- name: Install build + runtime-dev deps
|
||||
- name: Build + runtime-dev deps (no-op guard — baked into arch-ci)
|
||||
# No -Syu: the image's snapshot IS the build environment (see the Dockerfile's
|
||||
# rolling-release note); with everything installed this resolves locally and
|
||||
# does nothing. It only matters on the push that adds a dep before the image
|
||||
# rebuild lands — same bootstrap note as ci.yml's GTK4 step.
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
pacman -S --noconfirm --needed \
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
|
||||
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so bootstrap
|
||||
# the official binary.
|
||||
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
|
||||
command -v bun >/dev/null || {
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
|
||||
@@ -98,11 +112,26 @@ jobs:
|
||||
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
|
||||
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
|
||||
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
|
||||
#
|
||||
# The run number is ZERO-PADDED to a fixed width, and that padding is load-bearing.
|
||||
# pacman's own vercmp compares numeric segments numerically and gets this right either
|
||||
# way, but Gitea's Arch registry picks the version it advertises in `punktfunk-canary.db`
|
||||
# by STRING order. Unpadded, the run counter crossing a power of ten inverts that order
|
||||
# ("0.9907" > "0.10095" because '9' > '1'), so the db pins itself to the last build
|
||||
# before the rollover and every later canary becomes invisible to `pacman -Syu` — the
|
||||
# packages publish fine, the index just never names them. That is exactly what happened
|
||||
# on 2026-07-29 when run #10000 landed; it cost an evening and needed a manual purge of
|
||||
# every 4-digit `0.22.0-0.9xxx` version to unstick. Padding keeps string order and
|
||||
# numeric order in agreement, so the two can never disagree again.
|
||||
#
|
||||
# Keep the leading `0.` — it is what sorts a canary BELOW the eventual `X.Y.Z-1` stable
|
||||
# release. (A pkgrel is digits+dots only, so `0.` is the only prefix available; raising
|
||||
# it to `1.` would sort canaries ABOVE the release and is not an option.)
|
||||
run: |
|
||||
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
|
||||
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
|
||||
*) V="$PF_BASE"; R="0.$(printf '%08d' "$GITHUB_RUN_NUMBER")"; REPO=punktfunk-canary ;;
|
||||
esac
|
||||
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
|
||||
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
|
||||
@@ -132,9 +161,15 @@ jobs:
|
||||
sudo -u builder git config --global --add safe.directory "$PWD"
|
||||
mkdir -p dist && chown builder: dist
|
||||
cd packaging/arch
|
||||
# sudo env_reset strips the ambient env, so the sccache wiring must cross the
|
||||
# boundary explicitly (same values as the workflow env block).
|
||||
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
|
||||
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
|
||||
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
|
||||
RUSTC_WRAPPER="$RUSTC_WRAPPER" CARGO_INCREMENTAL="$CARGO_INCREMENTAL" \
|
||||
SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
|
||||
SCCACHE_REGION="$SCCACHE_REGION" \
|
||||
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
|
||||
makepkg -f -d --holdver
|
||||
ls -lh "$GITHUB_WORKSPACE/dist"
|
||||
|
||||
@@ -158,6 +193,7 @@ jobs:
|
||||
# failure building gamescope must not cost the packages this workflow exists to publish.
|
||||
run: |
|
||||
set -x
|
||||
# Baked into arch-ci — a no-op guard, like the dep step above.
|
||||
pacman -S --noconfirm --needed \
|
||||
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
|
||||
libxkbcommon libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
|
||||
|
||||
@@ -65,6 +65,15 @@ jobs:
|
||||
dockerfile: ci/fedora-rpm.Dockerfile
|
||||
buildargs: --build-arg FEDORA_VERSION=44
|
||||
keysuffix: -f44
|
||||
# Android builder (JDK + SDK/NDK + cargo-ndk + sccache) — android.yml and
|
||||
# android-screenshots.yml run in it; ~3 GB of per-run Google downloads became
|
||||
# image layers.
|
||||
- image: punktfunk-android-ci
|
||||
dockerfile: ci/android-ci.Dockerfile
|
||||
# Arch builder (base-devel + both makepkg legs' deps + bun + sccache) —
|
||||
# arch.yml runs in it; ~1 GB of per-run pacman traffic became image layers.
|
||||
- image: punktfunk-arch-ci
|
||||
dockerfile: ci/arch-ci.Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -64,8 +64,19 @@ jobs:
|
||||
container:
|
||||
# Fedora ships a recent flatpak + flatpak-builder + the kernel userns support.
|
||||
# --privileged is required for bubblewrap inside the Docker executor (see header).
|
||||
#
|
||||
# --network host is what finally fixed the years-long "Could not resolve
|
||||
# hostname" on every flathub fetch. MEASURED 2026-07-30 on home-runner-2, all
|
||||
# in ONE container: `getent hosts dl.flathub.org` resolved, `curl` got HTTP
|
||||
# 200 (both auto and -4), and flatpak still failed error [6] — so it was never
|
||||
# DNS config, the resolver, the docker version, or the per-job network. It is
|
||||
# ostree's own resolver refusing to work through Docker's embedded 127.0.0.11
|
||||
# (proven: rewriting resolv.conf to a real nameserver did NOT help, and the
|
||||
# default bridge failed too, while the host netns — no embedded resolver in the
|
||||
# path at all — works every time). Host networking also means this job no
|
||||
# longer needs the nsswitch surgery below to be lucky.
|
||||
image: fedora:43
|
||||
options: --privileged
|
||||
options: --privileged --network host
|
||||
steps:
|
||||
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
|
||||
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: the `resolve`
|
||||
|
||||
@@ -88,6 +88,21 @@ on:
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
|
||||
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
|
||||
# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift
|
||||
# side stays on DerivedData (sccache doesn't cache swiftc).
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_BUCKET: unom-ci-sccache
|
||||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||||
SCCACHE_REGION: home-central
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||||
# sccache and incremental compilation are mutually exclusive; the shared cache makes the
|
||||
# runner's persistent target/ disposable instead of precious.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
apple:
|
||||
runs-on: macos-arm64
|
||||
@@ -157,6 +172,18 @@ jobs:
|
||||
# inherits this from the env during the xcframework build).
|
||||
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
|
||||
|
||||
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
|
||||
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
|
||||
- name: sccache (self-healing install)
|
||||
run: |
|
||||
if ! command -v sccache >/dev/null; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
|
||||
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
|
||||
fi
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
sccache --version
|
||||
|
||||
- name: Pin + prune Xcode DerivedData
|
||||
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
|
||||
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
|
||||
|
||||
@@ -276,6 +276,18 @@ jobs:
|
||||
cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
|
||||
Pop-Location
|
||||
|
||||
# The console output is fully self-contained (Nitro noExternals) and most pushes
|
||||
# don't touch web/ or sdk/ — restore it from the central cache and skip the ~2.5 min
|
||||
# bun build+smoke entirely on a hit. First workflow on this runner to use the
|
||||
# actions cache at all (the runner's config.yaml needed cache.external_server —
|
||||
# see unom/infra runners/ci-core/README.md).
|
||||
- name: Cache web console output
|
||||
id: webconsole
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.output
|
||||
key: web-console-win-${{ hashFiles('web/**', 'sdk/**') }}
|
||||
|
||||
- name: Fetch portable bun runtime (build tool + bundled to run the console)
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -295,6 +307,7 @@ jobs:
|
||||
& $bun --version
|
||||
|
||||
- name: Build + smoke-boot web console (bun)
|
||||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
# PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
|
||||
@@ -335,6 +348,21 @@ jobs:
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "web console smoke (bun): /login -> $code"
|
||||
if ($code -ne 200) { throw "web console failed to boot under bun" }
|
||||
|
||||
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
|
||||
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
|
||||
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
|
||||
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
|
||||
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
|
||||
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
|
||||
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
|
||||
# The throw is the point: never silently ship a console-less installer again.
|
||||
- name: Export the console output dir (cache hit or fresh build)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path 'web\.output\server\index.mjs')) {
|
||||
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
|
||||
}
|
||||
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build plugin/script runner bundle (bun)
|
||||
@@ -355,6 +383,41 @@ jobs:
|
||||
}
|
||||
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
# NOT cached, and it must stay that way: the UMDF drivers build IN-TREE inside the pack
|
||||
# step (a relocated CARGO_TARGET_DIR breaks wdk-build's manifest walk), and act rotates
|
||||
# the job workspace path (~/.cache/act/<hash>/hostexecutor) between runs. A cargo target
|
||||
# dir restored under a DIFFERENT absolute path brings state that points at the old one:
|
||||
# measured 2026-07-30, `pf-umdf-util` died with 14 × "unable to create file lock (os
|
||||
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
|
||||
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
|
||||
# workspace-relative target.
|
||||
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
|
||||
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
|
||||
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
|
||||
# all of these, so here a missing input is a build failure rather than a quietly smaller
|
||||
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
|
||||
- name: Verify every installer payload is present
|
||||
shell: pwsh
|
||||
run: |
|
||||
$need = @(
|
||||
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
|
||||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||||
)
|
||||
$missing = @()
|
||||
foreach ($x in $need) {
|
||||
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
|
||||
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
|
||||
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
|
||||
else { Write-Output "payload OK - $($x.n) -> $full" }
|
||||
}
|
||||
if ($missing.Count) {
|
||||
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
|
||||
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
|
||||
}
|
||||
|
||||
- name: Pack + sign installer
|
||||
shell: pwsh
|
||||
env:
|
||||
@@ -439,6 +502,36 @@ jobs:
|
||||
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
|
||||
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
|
||||
# reads the manifests from the release, so it must not run before they are attached.
|
||||
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
|
||||
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
|
||||
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
|
||||
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
|
||||
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
|
||||
canary-manifest:
|
||||
needs: package
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Publish the canary update manifest
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
|
||||
eval "$(bash scripts/ci/pf-version.sh)"
|
||||
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
|
||||
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
|
||||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||||
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
|
||||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
|
||||
bash scripts/ci/publish-update-manifest.sh
|
||||
|
||||
winget-source:
|
||||
needs: package
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
|
||||
@@ -127,7 +127,10 @@ jobs:
|
||||
# hand-off shim. --no-default-features on ARM64 is a no-op for the shell.
|
||||
- name: Build (release)
|
||||
shell: pwsh
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
# punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1
|
||||
# requires (bf981027 added the requirement without the build — same gap 90c84ef4
|
||||
# closed for deb).
|
||||
run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }}
|
||||
|
||||
- name: Pack + sign MSIX
|
||||
shell: pwsh
|
||||
|
||||
@@ -139,16 +139,20 @@ jobs:
|
||||
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
|
||||
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
|
||||
# for the shell, which has no features).
|
||||
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
|
||||
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
|
||||
# RUN the binary (help contract), as the session's contract_smoke runs the session —
|
||||
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
|
||||
- name: Build
|
||||
shell: pwsh
|
||||
run: |
|
||||
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
|
||||
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
|
||||
|
||||
- name: Clippy (-D warnings)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
|
||||
$sf = @()
|
||||
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
|
||||
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
|
||||
@@ -156,9 +160,9 @@ jobs:
|
||||
- name: Rustfmt check
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
|
||||
|
||||
- name: Test
|
||||
if: matrix.target == 'x86_64-pc-windows-msvc'
|
||||
shell: pwsh
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
|
||||
|
||||
@@ -947,10 +947,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
"pf-frame",
|
||||
"pf-inject",
|
||||
"pf-vdisplay",
|
||||
"punktfunk-core",
|
||||
@@ -1035,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "display-disturb"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
@@ -2220,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2325,7 +2326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libvpl-sys"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
@@ -2360,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2849,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-capture"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2870,7 +2871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2895,7 +2896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-clipboard"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -2913,7 +2914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2934,7 +2935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-encode"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2958,7 +2959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2967,7 +2968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-frame"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
@@ -2979,7 +2980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-gpu"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-host-config",
|
||||
@@ -2993,11 +2994,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-host-config"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
|
||||
[[package]]
|
||||
name = "pf-inject"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3026,14 +3027,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-paths"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3048,7 +3049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ashpd",
|
||||
@@ -3081,7 +3082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-win-display"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-paths",
|
||||
@@ -3093,7 +3094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-zerocopy"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -3301,7 +3302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-cli"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
@@ -3312,7 +3313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3328,7 +3329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3345,18 +3346,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"glib-build-tools",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"pf-client-core",
|
||||
"pf-console-ui",
|
||||
"pf-presenter",
|
||||
"punktfunk-core",
|
||||
"relm4",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -3365,7 +3361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
@@ -3385,7 +3381,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3417,7 +3413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3501,7 +3497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3515,7 +3511,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
@@ -3538,7 +3534,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "pyrowave-sys"
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cmake",
|
||||
|
||||
@@ -51,7 +51,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.21.0"
|
||||
version = "0.22.3"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.21.0"
|
||||
"version": "0.22.2"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/clients": {
|
||||
@@ -3432,6 +3432,90 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/check": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Check for updates now",
|
||||
"description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.",
|
||||
"operationId": "forceUpdateCheck",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Refreshed update-check state (`last_error` carries a failed check)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Update checks are disabled on this host",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "A forced check ran less than 30 s ago",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/update/status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"update"
|
||||
],
|
||||
"summary": "Update-check status",
|
||||
"description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.",
|
||||
"operationId": "getUpdateStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current update-check state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -4799,6 +4883,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).",
|
||||
"required": [
|
||||
"version",
|
||||
"channel",
|
||||
"install_kind",
|
||||
"kind"
|
||||
],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "The channel it was announced on (`stable` | `canary`)."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call."
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"update.available"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The newer release's version string."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -7040,6 +7154,111 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateManifestInfo": {
|
||||
"type": "object",
|
||||
"description": "One channel's manifest facts, as much as the console renders.",
|
||||
"required": [
|
||||
"version",
|
||||
"serial",
|
||||
"published_at",
|
||||
"notes_url",
|
||||
"stale"
|
||||
],
|
||||
"properties": {
|
||||
"notes_url": {
|
||||
"type": "string",
|
||||
"description": "Release-notes link (pinned to our forge by the manifest validator)."
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"description": "RFC-3339 publish time (display only)."
|
||||
},
|
||||
"serial": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Publish serial (unix seconds) — monotonic per channel.",
|
||||
"minimum": 0
|
||||
},
|
||||
"stale": {
|
||||
"type": "boolean",
|
||||
"description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint."
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The released version this manifest announces."
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateStatus": {
|
||||
"type": "object",
|
||||
"description": "The full update-check state for this host.",
|
||||
"required": [
|
||||
"install_kind",
|
||||
"channel",
|
||||
"current_version",
|
||||
"apply",
|
||||
"channel_hint",
|
||||
"check_disabled",
|
||||
"available"
|
||||
],
|
||||
"properties": {
|
||||
"apply": {
|
||||
"type": "string",
|
||||
"description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)."
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)."
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Release channel this install follows: `stable` | `canary`."
|
||||
},
|
||||
"channel_hint": {
|
||||
"type": "string",
|
||||
"description": "The copy-pastable update command for this install kind."
|
||||
},
|
||||
"check_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)."
|
||||
},
|
||||
"current_version": {
|
||||
"type": "string",
|
||||
"description": "The running host version."
|
||||
},
|
||||
"install_kind": {
|
||||
"type": "string",
|
||||
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
|
||||
},
|
||||
"last_checked_unix": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "When the last successful check happened (unix seconds).",
|
||||
"minimum": 0
|
||||
},
|
||||
"last_error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Why the last check failed, verbatim, if it did."
|
||||
},
|
||||
"manifest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateManifestInfo",
|
||||
"description": "The last verified manifest, if any check has succeeded."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
@@ -7110,6 +7329,10 @@
|
||||
{
|
||||
"name": "store",
|
||||
"description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"
|
||||
},
|
||||
{
|
||||
"name": "update",
|
||||
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Android CI builder: JDK 21 + Android SDK/NDK/CMake + pinned Rust with the three shipping
|
||||
# Android targets + cargo-ndk + sccache. Everything android.yml used to download per run
|
||||
# (~3 GB of NDK + SDK packages from Google, plus a from-source cargo-ndk build) is baked
|
||||
# here instead; the image is content-keyed and rebuilt only when the ci/ tree changes
|
||||
# (docker.yml `builders`).
|
||||
#
|
||||
# docker build -f ci/android-ci.Dockerfile -t punktfunk-android-ci ci
|
||||
#
|
||||
# Version pins mirror what android.yml installed via sdkmanager: AGP 9.3 wants JDK 17–21;
|
||||
# cmake;3.22.1 because kit/build.gradle.kts prepends $ANDROID_SDK/cmake/3.22.1/bin to PATH
|
||||
# for cargo-ndk's audiopus_sys (libopus) CMake build; platforms;android-37 is deliberately
|
||||
# absent (AGP auto-downloads it if a build ever needs it — same note as the old workflow).
|
||||
FROM ubuntu:26.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git unzip zip python3 openjdk-21-jdk-headless \
|
||||
build-essential pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
|
||||
|
||||
# Android SDK: cmdline-tools must land under cmdline-tools/latest for sdkmanager to
|
||||
# find its own root.
|
||||
ENV ANDROID_HOME=/opt/android-sdk \
|
||||
ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
ARG CMDLINE_TOOLS=13114758
|
||||
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
|
||||
&& curl -fsSL -o /tmp/clt.zip "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \
|
||||
&& unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" \
|
||||
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
|
||||
&& rm /tmp/clt.zip
|
||||
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH
|
||||
RUN yes | sdkmanager --licenses >/dev/null \
|
||||
&& sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" \
|
||||
"ndk;30.0.14904198" "cmake;3.22.1" \
|
||||
&& chmod -R a+rX "$ANDROID_HOME"
|
||||
|
||||
# Toolchain shared across CI users (jobs may run as different uids) — same shape as
|
||||
# rust-ci.Dockerfile, plus the Android cross targets and cargo-ndk. The registry/git
|
||||
# download caches are stripped after the cargo-ndk install: jobs restore those from the
|
||||
# shared actions cache, and baking them would only bloat every pull.
|
||||
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 \
|
||||
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
|
||||
&& cargo install cargo-ndk --locked \
|
||||
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
|
||||
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
|
||||
&& rustc --version && cargo ndk --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& sccache --version
|
||||
|
||||
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
|
||||
# the job container — no node, no checkout (exit 127; same lesson flatpak.yml documents for
|
||||
# fedora:43). A separate trailing layer on purpose: appending here keeps the fat SDK/NDK
|
||||
# layers above cache-valid instead of invalidating the whole build.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& node --version
|
||||
@@ -0,0 +1,42 @@
|
||||
# Arch CI builder: base-devel + every dependency arch.yml's two makepkg legs used to
|
||||
# pacman-install per run (~1 GB of mirror traffic each time) + bun + sccache + nodejs
|
||||
# (JS actions exec node INSIDE the job container — the same lesson as android-ci).
|
||||
# Content-keyed and rebuilt only when the ci/ tree changes (docker.yml `builders`).
|
||||
#
|
||||
# docker build -f ci/arch-ci.Dockerfile -t punktfunk-arch-ci ci
|
||||
#
|
||||
# ROLLING-RELEASE TRADEOFF, on purpose: packages now build against the Arch snapshot
|
||||
# from the last image rebuild instead of a fresh -Syu per run. That is the same staleness
|
||||
# the gamescope cache already embraces ("a stale binary against newer system libs is the
|
||||
# same risk the distro's own package carries between rebuilds"), and any ci/ edit — or
|
||||
# bumping the date in this line (refreshed: 2026-07-29) — re-keys and re-snapshots it.
|
||||
FROM docker.io/library/archlinux:base-devel
|
||||
|
||||
# One transaction: the main build/runtime deps (first list) + the gamescope companion's
|
||||
# deps (second list) — both copied verbatim from what arch.yml installed in-job, where
|
||||
# they now no-op as `--needed` guards.
|
||||
RUN pacman -Syu --noconfirm --needed \
|
||||
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
|
||||
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
|
||||
mesa libglvnd unzip libarchive \
|
||||
glslang libcap libdrm libinput libx11 libxcomposite libxdamage libxext \
|
||||
libxmu libxrender libxres libxtst libxxf86vm libavif libdecor \
|
||||
hwdata luajit seatd sdl2-compat vulkan-icd-loader \
|
||||
xcb-util-errors xcb-util-wm xorg-xwayland \
|
||||
meson glm wayland-protocols benchmark libxcursor \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
|
||||
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
|
||||
# bootstrap the official binary — once, here, instead of per run.
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& rm -rf /root/.bun \
|
||||
&& bun --version
|
||||
|
||||
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
|
||||
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
|
||||
ARG SCCACHE_VERSION=0.10.0
|
||||
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
|
||||
&& sccache --version
|
||||
@@ -18,10 +18,14 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
@@ -36,6 +40,42 @@ import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
|
||||
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
|
||||
|
||||
/**
|
||||
* Keeps ONE window-insets reader alive for as long as the app's UI exists — the fix for the menus
|
||||
* coming back from a stream laid out against the WRONG safe area.
|
||||
*
|
||||
* Compose attaches its `OnApplyWindowInsets` and `WindowInsetsAnimation` callbacks when the first
|
||||
* composable reads an inset, and removes them again when the last reader goes away
|
||||
* (`WindowInsetsHolder.increment/decrementAccessors`). [StreamScreen] reads no insets at all — it's
|
||||
* a bare full-screen surface — so a stream drops the reader count to zero for its whole duration.
|
||||
*
|
||||
* That alone is survivable; what isn't is a session that ends while the app is BACKGROUNDED, which
|
||||
* is the common case (leaving the app ends the session — see StreamScreen's ON_STOP observer). The
|
||||
* whole window restore — `show(systemBars())`, releasing the landscape lock — then runs on a stopped
|
||||
* activity, and the corrected insets that follow arrive while Compose has no listener attached. When
|
||||
* the menus recompose, `incrementAccessors` re-attaches and asks for a fresh pass, but a stopped
|
||||
* window produces no dispatch, and on resume nothing has *changed* any more, so none ever comes.
|
||||
* Compose keeps serving what it last saw: the landscape, bars-hidden values.
|
||||
*
|
||||
* That's exactly what the reporter's phone showed (on-glass 2026-07-29, verified by dump): the
|
||||
* platform reported `bars=[0,162,0,72] cutout=[0,162,0,0]` for the window while the layout was still
|
||||
* using the landscape immersive set — cutout `left=162` (Material3 lays out against
|
||||
* `systemBars.union(displayCutout)`), bars all zero. Content shoved right by the landscape cutout,
|
||||
* nothing kept clear of the status bar or the gesture pill, and no rotation or IME animation could
|
||||
* shake it loose. A/B'd over eight runs of the real teardown sequence: 3 of 4 wrong without this,
|
||||
* 4 of 4 correct with it.
|
||||
*
|
||||
* Reading an inset here holds the count above zero for the activity's whole life, so the listeners
|
||||
* survive the stream and every dispatch lands. It subscribes to no inset VALUE (only the holder
|
||||
* object), so it triggers no recomposition — the cost is one DisposableEffect.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldWindowInsetsListeners() {
|
||||
// The read itself IS the registration (the accessor is scoped to this composable, which never
|
||||
// leaves the composition); `remember` is only what keeps it from being a value nobody uses.
|
||||
remember(WindowInsets.systemBars) {}
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
/**
|
||||
* The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown.
|
||||
@@ -207,6 +247,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
setContent {
|
||||
PunktfunkTheme {
|
||||
HoldWindowInsetsListeners()
|
||||
// Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is
|
||||
// the bootstrap: directional moves need an already-focused node, while one-
|
||||
// dimensional traversal assigns initial focus when there is none.
|
||||
|
||||
@@ -32,7 +32,13 @@ class MouseForwarder(
|
||||
private val handle: Long,
|
||||
private val invertScroll: Boolean,
|
||||
private val captureWanted: Boolean,
|
||||
private val surfaceSize: () -> Pair<Int, Int>,
|
||||
/**
|
||||
* The picture's rect in WINDOW coordinates — where the letterboxed video actually sits, which is
|
||||
* the frame absolute positions must be measured against. Events arrive from the activity's
|
||||
* dispatch overrides in window coordinates, so a stream narrower than the panel needs the origin
|
||||
* subtracted as well as the size divided; `null` while the surface isn't laid out yet.
|
||||
*/
|
||||
private val videoRect: () -> android.graphics.Rect?,
|
||||
) {
|
||||
/** Capture plumbing, owned by StreamScreen (the focusable capture view). */
|
||||
var onRequestCapture: (() -> Unit)? = null
|
||||
@@ -152,12 +158,16 @@ class MouseForwarder(
|
||||
}
|
||||
|
||||
private fun sendAbs(ev: MotionEvent) {
|
||||
val (w, h) = surfaceSize()
|
||||
val r = videoRect() ?: return
|
||||
val w = r.width()
|
||||
val h = r.height()
|
||||
if (w <= 0 || h <= 0) return
|
||||
// Clamped into the picture: a pointer out on a letterbox bar has no host position of its
|
||||
// own, and the edge is the honest answer for it.
|
||||
NativeBridge.nativeSendPointerAbs(
|
||||
handle,
|
||||
ev.x.roundToInt().coerceIn(0, w - 1),
|
||||
ev.y.roundToInt().coerceIn(0, h - 1),
|
||||
(ev.x - r.left).roundToInt().coerceIn(0, w - 1),
|
||||
(ev.y - r.top).roundToInt().coerceIn(0, h - 1),
|
||||
w,
|
||||
h,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -197,6 +198,11 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
// below so the capture callbacks can reach the view once it exists.
|
||||
var keyCapture by remember { mutableStateOf<KeyCaptureView?>(null) }
|
||||
|
||||
// The video SurfaceView, hoisted for the same reason: the pointer paths built below map WINDOW
|
||||
// coordinates onto the picture, and with a letterboxed stream that rect is the video's, not the
|
||||
// panel's. Set when the view is created.
|
||||
var videoView by remember { mutableStateOf<SurfaceView?>(null) }
|
||||
|
||||
DisposableEffect(handle) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
wifiLocks.forEach { lock ->
|
||||
@@ -256,7 +262,15 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
handle,
|
||||
invertScroll = initialSettings.invertScroll,
|
||||
captureWanted = initialSettings.mouseMode == MouseMode.CAPTURE,
|
||||
surfaceSize = { (decor?.width ?: 0) to (decor?.height ?: 0) },
|
||||
// The picture's rect in window coordinates (see MouseForwarder.videoRect) — read live,
|
||||
// so it is right from the frame the SurfaceView is first laid out.
|
||||
videoRect = {
|
||||
videoView?.takeIf { it.width > 0 && it.height > 0 }?.let { v ->
|
||||
val loc = IntArray(2)
|
||||
v.getLocationInWindow(loc)
|
||||
android.graphics.Rect(loc[0], loc[1], loc[0] + v.width, loc[1] + v.height)
|
||||
}
|
||||
},
|
||||
)
|
||||
mouse.onRequestCapture = {
|
||||
// The grab needs the (focusable) capture view: focus it, then ask. Posted so a
|
||||
@@ -275,7 +289,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
val remote = if (isTv) {
|
||||
RemotePointer(
|
||||
handle,
|
||||
surfaceWidth = { decor?.width ?: 1920 },
|
||||
surfaceWidth = { videoView?.width?.takeIf { it > 0 } ?: decor?.width ?: 1920 },
|
||||
onActiveChanged = { on -> remotePointerOn = on },
|
||||
onKeyboardToggle = { keyCapture?.let { it.setImeVisible(!it.imeShown) } },
|
||||
)
|
||||
@@ -423,11 +437,33 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
activity?.mouseForwarder?.engageFromStart()
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Fit the picture to the stream's own aspect, letterboxing the rest in black. MediaCodec scales
|
||||
// whatever it decodes to fill the Surface it renders into, so a 16:9 stream on a 20:9 panel came
|
||||
// out stretched — the surface has to carry the aspect, because nothing downstream of it can.
|
||||
// The mode is the negotiated one (known from the handshake, before the first frame); 0/absent —
|
||||
// an older native lib — falls back to filling, i.e. exactly the previous behaviour.
|
||||
val videoAspect = remember(handle) {
|
||||
val size = NativeBridge.nativeVideoSize(handle)
|
||||
val w = size?.getOrNull(0) ?: 0
|
||||
val h = size?.getOrNull(1) ?: 0
|
||||
if (w > 0 && h > 0) w.toFloat() / h.toFloat() else 0f
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||
// One rect for the picture AND for the input that lands on it. Every absolute mapping —
|
||||
// direct-pointer touch, multi-touch passthrough, the pen lane — measures against the size of
|
||||
// the node it sits on, so putting the gesture layer on this same rect keeps all three correct
|
||||
// by construction rather than by threading an offset through each of them. The cost is that
|
||||
// trackpad swipes starting inside a letterbox bar don't register; the picture is the surface.
|
||||
val videoFit = if (videoAspect > 0f) {
|
||||
Modifier.align(Alignment.Center).aspectRatio(videoAspect)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = videoFit,
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).apply {
|
||||
videoView = this
|
||||
holder.addCallback(object : SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
// Low-latency mode: rank MediaCodecList decoders for the negotiated
|
||||
@@ -517,7 +553,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
LaunchedEffect(stylus) { stylus.heartbeatLoop() }
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().pointerInput(handle, touchMode) {
|
||||
videoFit.pointerInput(handle, touchMode) {
|
||||
when (touchMode) {
|
||||
TouchMode.TOUCH -> streamTouchPassthrough(handle, stylus)
|
||||
else -> streamTouchInput(
|
||||
|
||||
@@ -69,9 +69,9 @@ data class HostMenuItem(
|
||||
)
|
||||
|
||||
/**
|
||||
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
|
||||
* saved hosts) an overflow menu with Wake / Edit / Forget plus whatever [menuItems] adds. Tapping
|
||||
* the card connects.
|
||||
* A host as an Apple-style card: a colored avatar carrying the host's OS mark (its initial when we
|
||||
* don't know the OS), name + address, a trust pill, and (for saved hosts) an overflow menu with
|
||||
* Wake / Edit / Forget plus whatever [menuItems] adds. Tapping the card connects.
|
||||
*
|
||||
* [profileLabel] names the settings profile this card connects with. On a host's own card that is
|
||||
* its default binding, drawn as a quiet chip — the card says what a tap will do. On a **pinned
|
||||
@@ -84,7 +84,7 @@ fun HostCard(
|
||||
address: String,
|
||||
status: HostStatus,
|
||||
online: Boolean = false,
|
||||
/** OS-identity chain (mDNS `os` TXT / stored), for the address line's OS mark. "" = none. */
|
||||
/** OS-identity chain (mDNS `os` TXT / stored), drawn as the avatar's mark. "" = the initial. */
|
||||
os: String = "",
|
||||
enabled: Boolean,
|
||||
onConnect: () -> Unit,
|
||||
@@ -129,7 +129,7 @@ fun HostCard(
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
HostAvatar(name, online)
|
||||
HostAvatar(name, online, os)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
name,
|
||||
@@ -138,28 +138,14 @@ fun HostCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// The OS mark leads the address line; absent entirely for a host that
|
||||
// doesn't advertise one, so those cards render exactly as they always did.
|
||||
val osIcon = resolveOsIcon(os)
|
||||
if (osIcon != null) {
|
||||
Icon(
|
||||
osIcon,
|
||||
contentDescription = os,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
address,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (profileLabel != null || reserveProfileSlot) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
@@ -283,8 +269,9 @@ private val PROFILE_CHIP_SLOT = 26.dp
|
||||
private val PRESENCE_ONLINE = Color(0xFF4ADE80)
|
||||
|
||||
/**
|
||||
* The host's letter avatar (Apple-contact style) with its presence as a dot on the corner — the
|
||||
* idiom every contact list already uses, and one fewer labelled badge on a small card.
|
||||
* The host's avatar (Apple-contact style) with its presence as a dot on the corner — the idiom
|
||||
* every contact list already uses, and one fewer labelled badge on a small card. It carries the
|
||||
* host's OS mark when [os] resolves to one we ship, and the host's initial otherwise.
|
||||
*
|
||||
* [online] is true when the host advertises on mDNS OR answers the reachability probe, so a
|
||||
* routed/VPN host that never advertises still reads as up. Online is a FILLED green dot, offline a
|
||||
@@ -292,9 +279,10 @@ private val PRESENCE_ONLINE = Color(0xFF4ADE80)
|
||||
* colour-blind reader and a screenshot in greyscale. TalkBack gets the word either way.
|
||||
*/
|
||||
@Composable
|
||||
fun HostAvatar(name: String, online: Boolean = false) {
|
||||
fun HostAvatar(name: String, online: Boolean = false, os: String = "") {
|
||||
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val cardColor = CardDefaults.elevatedCardColors().containerColor
|
||||
val osIcon = resolveOsIcon(os)
|
||||
Box {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -303,11 +291,23 @@ fun HostAvatar(name: String, online: Boolean = false) {
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
letter,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
// The OS mark IS the avatar when we know the OS — it identifies the machine better than
|
||||
// the initial ever did, and it's the same circle, so a card whose host advertises no OS
|
||||
// (or one we ship no mark for) keeps the letter and the row still reads as one set.
|
||||
if (osIcon != null) {
|
||||
Icon(
|
||||
osIcon,
|
||||
contentDescription = os,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
letter,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -47,7 +47,11 @@ dependencies {
|
||||
// /README.md): `cargo install cargo-ndk` + `rustup target add aarch64-linux-android x86_64-linux-android`.
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
val repoRoot = rootDir.parentFile.parentFile // clients/android -> clients -> repo root
|
||||
val cargoBin = "${System.getProperty("user.home")}/.cargo/bin"
|
||||
// CARGO_HOME first: rustup puts every binary in $CARGO_HOME/bin, and the CI image
|
||||
// (ci/android-ci.Dockerfile) installs the shared toolchain at /usr/local/cargo — the
|
||||
// historical ~/.cargo fallback is what a GUI Android Studio launch (no env) still needs.
|
||||
val cargoBin = System.getenv("CARGO_HOME")?.let { "$it/bin" }
|
||||
?: "${System.getProperty("user.home")}/.cargo/bin"
|
||||
|
||||
// SDK location without depending on AGP's DSL (sdkDirectory isn't in AGP 9's library extension):
|
||||
// env first (set by Android Studio and by our CLI shell), then local.properties, then the default.
|
||||
|
||||
@@ -183,6 +183,14 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeVideoMime(handle: Long): String
|
||||
|
||||
/**
|
||||
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
|
||||
* handshake, so it is known before the first frame — the stream view sizes itself to THIS
|
||||
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
|
||||
* Cheap; UI-safe.
|
||||
*/
|
||||
external fun nativeVideoSize(handle: Long): IntArray?
|
||||
|
||||
/**
|
||||
* A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` /
|
||||
* `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from
|
||||
|
||||
@@ -21,7 +21,10 @@ use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
|
||||
use super::{
|
||||
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
|
||||
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
|
||||
};
|
||||
|
||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||
@@ -259,6 +262,10 @@ pub(super) fn run_async(
|
||||
// first frame — the missed opening IDR — is caught by the same window.
|
||||
let mut last_output = Instant::now();
|
||||
let mut fed_at_output: u64 = 0;
|
||||
// Nothing-ever-arrived backstop (see [`NO_VIDEO_PATIENCE`]) — the mirror of the one above, for a
|
||||
// session whose video plane delivers no AU at all.
|
||||
let started = Instant::now();
|
||||
let mut last_no_video_req: Option<Instant> = None;
|
||||
|
||||
while !shutdown.load(Ordering::Relaxed) && !fatal {
|
||||
// Block for the next event (idle wait — excluded from the work tally). The short timeout
|
||||
@@ -388,6 +395,23 @@ pub(super) fn run_async(
|
||||
last_output = now; // one request per patience window, not per iteration
|
||||
fed_at_output = fed;
|
||||
}
|
||||
// Nothing has EVER arrived: not an idle stream but a session that never got a picture — the
|
||||
// `starved` test above cannot see it, because it needs `fed` to have moved. Evaluated after
|
||||
// `feed_ready`, so an AU that arrived this pass has either been fed or is parked in
|
||||
// `pending_aus`; both mean video IS flowing.
|
||||
let no_video_yet = fed == 0 && pending_aus.is_empty();
|
||||
if no_video_yet
|
||||
&& now.duration_since(started) >= NO_VIDEO_PATIENCE
|
||||
&& last_no_video_req.is_none_or(|t| now.duration_since(t) >= NO_VIDEO_RETRY)
|
||||
{
|
||||
log::warn!(
|
||||
"decode: no video received {} ms into the session — requesting a keyframe",
|
||||
now.duration_since(started).as_millis()
|
||||
);
|
||||
last_no_video_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
last_kf_req = Some(now); // share the throttle with the loss-recovery path below
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0 || starved)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
|
||||
@@ -62,6 +62,26 @@ const RENDERED_CAP: usize = 64;
|
||||
/// costs half a second before it self-heals is not a bug the user reports.
|
||||
const NO_OUTPUT_PATIENCE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// How long a session may deliver NO access unit at all before we ask for a keyframe and say so.
|
||||
///
|
||||
/// [`NO_OUTPUT_PATIENCE`] covers "fed but silent", and it deliberately requires `fed` to have moved
|
||||
/// so an idle stream never asks for anything. That leaves its mirror image uncovered: a session that
|
||||
/// receives nothing whatsoever. A decoder cannot be starved of output when it was handed no input,
|
||||
/// so no signal in either loop fires, and the session sits connected — audio, input and the control
|
||||
/// plane all alive — behind a black surface with a HUD reading `0 fps · 0.0 Mb/s`, which is exactly
|
||||
/// how it comes back in reports (2026-07-30).
|
||||
///
|
||||
/// Asking costs one small control message, and it is the right ask in the case we can actually fix:
|
||||
/// the host is encoding, but under infinite GOP every picture it sends references an IDR this client
|
||||
/// never saw. When the host is sending nothing at all, the request changes nothing — but the log line
|
||||
/// beside it is what separates that from "we received AUs and lost them", which no previous black
|
||||
/// screen report could tell us.
|
||||
const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(1500);
|
||||
|
||||
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
||||
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
||||
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
|
||||
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
||||
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
||||
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
||||
|
||||
@@ -24,7 +24,10 @@ use super::setup::{
|
||||
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
|
||||
configure_low_latency, create_codec, try_set_frame_rate,
|
||||
};
|
||||
use super::{DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
|
||||
use super::{
|
||||
DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE, NO_VIDEO_RETRY,
|
||||
PENDING_SPLIT_CAP,
|
||||
};
|
||||
|
||||
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
|
||||
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
|
||||
@@ -150,6 +153,10 @@ pub(super) fn run_sync(
|
||||
// missed opening IDR — is caught by the same window.
|
||||
let mut last_output = Instant::now();
|
||||
let mut fed_at_output: u64 = 0;
|
||||
// Nothing-ever-arrived backstop (see [`NO_VIDEO_PATIENCE`]) — the mirror of the one above, for a
|
||||
// session whose video plane delivers no AU at all.
|
||||
let started = Instant::now();
|
||||
let mut last_no_video_req: Option<Instant> = None;
|
||||
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||
let mut oversized_dropped: u64 = 0;
|
||||
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
||||
@@ -388,6 +395,23 @@ pub(super) fn run_sync(
|
||||
last_output = now; // one request per patience window, not per iteration
|
||||
fed_at_output = fed;
|
||||
}
|
||||
// Nothing has EVER arrived: not an idle stream but a session that never got a picture — the
|
||||
// `starved` test above cannot see it, because it needs `fed` to have moved. `pending` holds
|
||||
// an AU waiting for a free input buffer, so an empty one alongside `fed == 0` means the video
|
||||
// plane has delivered nothing at all.
|
||||
let no_video_yet = fed == 0 && pending.is_none();
|
||||
if no_video_yet
|
||||
&& now.duration_since(started) >= NO_VIDEO_PATIENCE
|
||||
&& last_no_video_req.is_none_or(|t| now.duration_since(t) >= NO_VIDEO_RETRY)
|
||||
{
|
||||
log::warn!(
|
||||
"decode: no video received {} ms into the session — requesting a keyframe",
|
||||
now.duration_since(started).as_millis()
|
||||
);
|
||||
last_no_video_req = Some(now);
|
||||
let _ = client.request_keyframe();
|
||||
last_kf_req = Some(now); // share the throttle with the loss-recovery path below
|
||||
}
|
||||
if (gate.poll(client.frames_dropped(), now) || starved)
|
||||
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ use jni::objects::JObject;
|
||||
// Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
|
||||
#[cfg(target_os = "android")]
|
||||
use jni::objects::JString;
|
||||
use jni::sys::{jboolean, jdoubleArray, jlong, jsize, jstring};
|
||||
use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use super::{jni_guard, SessionHandle};
|
||||
@@ -263,6 +263,36 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
|
||||
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
|
||||
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
|
||||
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
|
||||
/// on the host build too. Cheap; safe on the UI thread.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
|
||||
env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jintArray {
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let mode = h.client.mode();
|
||||
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
|
||||
let arr = match env.new_int_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
if env.set_int_array_region(&arr, 0, &buf).is_err() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
arr.into_raw()
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSetVideoStatsEnabled(handle, enabled)` — gate per-frame stats sampling on the
|
||||
/// HUD actually being visible: while disabled the decode thread skips the clock read + lock per AU.
|
||||
/// Enabling resets the measurement window so a later show never reports stale data. Sticky for the
|
||||
|
||||
@@ -54,6 +54,9 @@ private struct HomeTile: Identifiable {
|
||||
var hasLibrary = false
|
||||
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
|
||||
var icon: String?
|
||||
/// The host's OS-identity chain. When we ship art for it, the badge wears the OS mark instead
|
||||
/// of the title's initial — the same substitution the touch cards make.
|
||||
var osChain: String?
|
||||
/// Offline saved host we hold a MAC for (and WoL is available) — activating it wakes first.
|
||||
var canWake = false
|
||||
let activate: () -> Void
|
||||
@@ -284,6 +287,7 @@ struct GamepadHomeView: View {
|
||||
// A pinned card is a shortcut, not a second host — Y (library) stays on the
|
||||
// host's own tile, where the host-level actions live.
|
||||
hasLibrary: profile == nil,
|
||||
osChain: host.osChain,
|
||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||
&& !online && !host.wakeMacs.isEmpty,
|
||||
activate: {
|
||||
@@ -297,6 +301,7 @@ struct GamepadHomeView: View {
|
||||
title: d.name,
|
||||
subtitle: "\(d.host):\(String(d.port))",
|
||||
isOnline: true,
|
||||
osChain: d.osChain,
|
||||
activate: { connectDiscovered(d) })
|
||||
}
|
||||
let add = HomeTile(
|
||||
@@ -420,6 +425,15 @@ private struct GamepadHostTile: View {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||
.foregroundStyle(Color.brand)
|
||||
} else if let mark = osIconImage(for: tile.osChain) {
|
||||
// The OS mark stands in for the initial (template asset — tints like the text it
|
||||
// replaces), and carries the label, since nothing else on the tile names the OS.
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: Self.monogramFont, height: Self.monogramFont)
|
||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||
.accessibilityLabel(tile.osChain ?? "")
|
||||
} else {
|
||||
Text(monogram(tile.title))
|
||||
.font(.geistFixed(Self.monogramFont, .bold))
|
||||
|
||||
@@ -38,9 +38,16 @@ private func monogram(_ name: String) -> String {
|
||||
return String(first).uppercased()
|
||||
}
|
||||
|
||||
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||
/// The squared host tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a tinted
|
||||
/// outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
///
|
||||
/// `mark` is the host's OS mark, and it REPLACES the monogram when we have one: it identifies the
|
||||
/// machine better than its initial ever did, and on a row of similarly-named boxes the initial says
|
||||
/// nothing the name beneath it doesn't already say. A host that advertises no OS chain — or one we
|
||||
/// ship no art for — keeps its letter, so a mixed row still reads as one set.
|
||||
private func monogramTile(
|
||||
_ letter: String, osChain: String?, m: CardMetrics, connecting: Bool, filled: Bool
|
||||
) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(filled
|
||||
@@ -50,6 +57,16 @@ private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, fi
|
||||
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||
if connecting {
|
||||
ProgressView().tint(filled ? .white : Color.brand)
|
||||
} else if let mark = osIconImage(for: osChain) {
|
||||
// Template asset — tints from foregroundStyle exactly like the letter it stands in for.
|
||||
// Labelled, because this is where the OS is now announced: it used to ride the status
|
||||
// row below, which no longer carries it.
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: m.monogram, height: m.monogram)
|
||||
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||
.accessibilityLabel(osChain ?? "")
|
||||
} else {
|
||||
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||
@@ -133,7 +150,8 @@ struct HostCardView: View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
monogramTile(monogram(host.displayName), osChain: host.osChain,
|
||||
m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// The chip rides the TITLE line, anchored to the card's trailing edge — not
|
||||
// trailing the name, where it read as part of the title, and not on a line of
|
||||
@@ -295,16 +313,7 @@ struct HostCardView: View {
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
// The host's OS mark leads the row (template asset — tints like an SF Symbol);
|
||||
// absent entirely for a host that never advertised one, so those cards render
|
||||
// exactly as they always did.
|
||||
if let mark = osIconImage(for: host.osChain) {
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: m.status + 2, height: m.status + 2)
|
||||
.accessibilityLabel(host.osChain ?? "")
|
||||
}
|
||||
// The OS mark used to lead this row; it is the tile's glyph now (see monogramTile).
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
@@ -364,7 +373,8 @@ struct DiscoveredCardView: View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||
monogramTile(monogram(discovered.name), osChain: discovered.osChain,
|
||||
m: m, connecting: false, filled: false)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(discovered.name)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
@@ -375,14 +385,7 @@ struct DiscoveredCardView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
// Same leading OS mark as a saved card's status row — live from the advert.
|
||||
if let mark = osIconImage(for: discovered.osChain) {
|
||||
mark
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: m.status + 2, height: m.status + 2)
|
||||
.accessibilityLabel(discovered.osChain)
|
||||
}
|
||||
// The advert's OS mark is the tile's glyph now (see monogramTile).
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
|
||||
@@ -116,7 +116,10 @@ shoot_sim() {
|
||||
xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true
|
||||
|
||||
log "$prefix — building ($scheme)…"
|
||||
local dd; dd="$(mktemp -d)"
|
||||
# PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the
|
||||
# incremental build instead of cold-building into a throwaway tmpdir — CI pins this
|
||||
# (apple.yml); local runs keep the self-cleaning mktemp default.
|
||||
local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd"
|
||||
xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \
|
||||
-sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \
|
||||
CODE_SIGNING_ALLOWED=NO build >/dev/null \
|
||||
|
||||
@@ -59,7 +59,131 @@ punktfunk — the Punktfunk client, headless
|
||||
|
||||
A <host-ref> is a saved host's id, its name, or an address — the same reference a
|
||||
punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found,
|
||||
6 needs a person.";
|
||||
6 needs a person.
|
||||
|
||||
\"punktfunk help <command>\" (or any command with --help) explains that command.";
|
||||
|
||||
/// The long help for one verb — `punktfunk help <verb>`, or `--help` after the verb.
|
||||
/// Each entry documents its flags and the behaviour a script would need to know
|
||||
/// (what goes to stdout vs stderr, and which exit codes mean what).
|
||||
fn verb_help(verb: &str) -> Option<&'static str> {
|
||||
Some(match verb {
|
||||
"pair" => {
|
||||
"\
|
||||
punktfunk pair <host[:port]> — enrol this device with a host (PIN ceremony)
|
||||
|
||||
--pin N the PIN the host is showing; without it the command asks, and
|
||||
refuses (exit 6) when there is no terminal to ask on
|
||||
--name LABEL the label the host files this device under
|
||||
(default: this machine's name)
|
||||
|
||||
Pairing verifies the host end-to-end and pins its fingerprint in the saved-hosts
|
||||
store, so every later connect — here, in the desktop client or the console — is
|
||||
silent. The port defaults to 9777. Prints `paired <addr>:<port> fp=<hex>` on
|
||||
success; exit 3 if the host refuses or the PIN is wrong."
|
||||
}
|
||||
"hosts" => {
|
||||
"\
|
||||
punktfunk hosts — the saved-hosts store (shared with the desktop client)
|
||||
|
||||
punktfunk hosts list [--probe] [--json]
|
||||
Every saved host, name TAB addr:port TAB paired/trusted TAB state.
|
||||
--probe asks each host directly (no mDNS, so routed/VPN hosts answer
|
||||
too); --json emits one object with per-host detail, profiles included.
|
||||
|
||||
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
|
||||
Save a host by address — the door for a box mDNS never sees (Tailscale,
|
||||
another subnet). Without --fp it is a placeholder to pair later; with a
|
||||
64-hex fingerprint it is pinned immediately (still unpaired).
|
||||
|
||||
punktfunk hosts forget <host-ref>
|
||||
Remove a saved host, its pinned fingerprint included. A later connect
|
||||
must pair or trust it again."
|
||||
}
|
||||
"wake" => {
|
||||
"\
|
||||
punktfunk wake <host-ref> [--wait] — Wake-on-LAN
|
||||
|
||||
Sends a magic packet to a saved host's MAC (learned from its advert while it
|
||||
was awake; exit 5 if none is known yet). With --wait, keeps sending every 6 s
|
||||
and polls presence every second for up to 90 s, exiting 0 the moment the host
|
||||
answers — the same cadence every graphical shell uses."
|
||||
}
|
||||
"library" => {
|
||||
"\
|
||||
punktfunk library <host-ref> [--json] — the host's game library
|
||||
|
||||
TSV on stdout by default (id TAB store TAB title), one game per line; --json
|
||||
emits {\"games\":[…]} for tools — the Playnite importer shells to exactly
|
||||
this. Needs a paired host (exit 6 otherwise)."
|
||||
}
|
||||
"launch" => {
|
||||
"\
|
||||
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
|
||||
|
||||
Start a stream — waking the host first if it is asleep and its MAC is known.
|
||||
The stream runs in the punktfunk-session renderer; this command supervises it
|
||||
and relays its lifecycle to stderr.
|
||||
|
||||
--game ID ask the host to launch this library title into the stream
|
||||
--profile REF use a settings profile (id or name) for this connect only;
|
||||
without it the host's own binding applies
|
||||
--fullscreen start the stream window fullscreen
|
||||
--exec become the session process instead of supervising it — the
|
||||
gamescope-wrapper mode, where the launched process must BE
|
||||
the streaming one for focus and lifecycle to work
|
||||
|
||||
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
|
||||
trusts this device (re-pair), 4 the renderer could not start."
|
||||
}
|
||||
"open" => {
|
||||
"\
|
||||
punktfunk open <punktfunk://…> — follow a punktfunk:// link, headless
|
||||
|
||||
Same parser and same refusal rules as clicking the link in a shell: a
|
||||
contradicted fingerprint refuses and says so, an ambiguous name refuses
|
||||
rather than guessing, and an unknown host is never trusted from a URL —
|
||||
that is a decision for a person, at a surface that can show the fingerprint
|
||||
(exit 6 points at `punktfunk pair`). --exec as in launch."
|
||||
}
|
||||
"reachable" => {
|
||||
"\
|
||||
punktfunk reachable <host-ref> — one bounded reachability probe
|
||||
|
||||
Asks the host directly (no mDNS), so routed/VPN hosts answer too. The
|
||||
reference may be an unsaved address — this verb answers \"can I reach it\",
|
||||
not \"do I know it\". Exit 0 reachable, 2 not; one line either way."
|
||||
}
|
||||
"speed-test" => {
|
||||
"\
|
||||
punktfunk speed-test <host-ref> [--json] — measure the real data plane
|
||||
|
||||
Runs the host's bandwidth probe over an actual session connect and prints the
|
||||
measured throughput, loss, and the bitrate it recommends. Deliberately does
|
||||
NOT apply the result: which layer a bitrate belongs in (a bound profile, the
|
||||
global default) is a decision the GUI makes with the user, and a CLI silently
|
||||
rewriting settings would be exactly the surprise that rule exists to prevent."
|
||||
}
|
||||
"profiles" => {
|
||||
"\
|
||||
punktfunk profiles list [--json] — the settings profiles on this device
|
||||
|
||||
One line per profile: id TAB name TAB how many settings it overrides.
|
||||
Profiles are created and edited in the desktop client; a connect uses one via
|
||||
`punktfunk launch --profile` or a punktfunk:// link that names it."
|
||||
}
|
||||
"reset" => {
|
||||
"\
|
||||
punktfunk reset — forget every saved host and reset stream settings
|
||||
|
||||
Asks for confirmation, and refuses (exit 6) when there is no terminal to ask
|
||||
on. This device's identity keypair is deliberately kept, so hosts that knew
|
||||
this machine still recognise it after re-pairing; delete the identity files
|
||||
from the config directory for a true factory reset."
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The value after `--flag`, if any.
|
||||
fn value(args: &[String], flag: &str) -> Option<String> {
|
||||
@@ -138,6 +262,12 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
return OK;
|
||||
};
|
||||
let rest: Vec<String> = args[1..].to_vec();
|
||||
// `--help`/`-h` after any verb prints that verb's help — before dispatch, so a verb
|
||||
// never mistakes the flag for its subject (`punktfunk pair -h` must not dial "-h").
|
||||
if rest.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!("{}", verb_help(&verb).unwrap_or(USAGE));
|
||||
return OK;
|
||||
}
|
||||
match verb.as_str() {
|
||||
"pair" => pair(&rest),
|
||||
"hosts" => hosts(&rest),
|
||||
@@ -149,10 +279,22 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
"speed-test" => speed_test(&rest),
|
||||
"profiles" => profiles(&rest),
|
||||
"reset" => reset(),
|
||||
"-h" | "--help" | "help" => {
|
||||
println!("{USAGE}");
|
||||
OK
|
||||
}
|
||||
"-h" | "--help" | "help" => match positional(&rest, 0) {
|
||||
None => {
|
||||
println!("{USAGE}");
|
||||
OK
|
||||
}
|
||||
Some(topic) => match verb_help(&topic) {
|
||||
Some(h) => {
|
||||
println!("{h}");
|
||||
OK
|
||||
}
|
||||
None => {
|
||||
eprintln!("no command called \"{topic}\"\n\n{USAGE}");
|
||||
UNRESOLVED
|
||||
}
|
||||
},
|
||||
},
|
||||
"--version" | "version" => {
|
||||
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
|
||||
OK
|
||||
@@ -600,10 +742,17 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
return UNRESOLVED;
|
||||
};
|
||||
// An address that isn't saved is still a legitimate thing to probe — this verb answers
|
||||
// "can I reach this?", not "do I know this?".
|
||||
let (addr, port) = match resolve(&reference) {
|
||||
Ok((known, i)) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
Err(_) => split_host_port(&reference),
|
||||
// "can I reach this?", not "do I know this?". Resolved QUIETLY for the same reason:
|
||||
// `resolve`'s "pair it first" advice is for verbs that need a saved host, and printing
|
||||
// it here would scold the exact usage this verb documents.
|
||||
let known = KnownHosts::load();
|
||||
let link = DeepLink {
|
||||
host_ref: reference.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let (addr, port) = match deeplink::resolve_host(&link, &known) {
|
||||
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
|
||||
_ => split_host_port(&reference),
|
||||
};
|
||||
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||
println!("reachable {addr}:{port}");
|
||||
@@ -769,15 +918,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
/// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with
|
||||
/// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX.
|
||||
fn is_tty() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY-free check: `isatty` via the std file descriptor, without libc.
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -820,6 +961,32 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
|
||||
assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777));
|
||||
}
|
||||
|
||||
/// Every advertised verb documents itself — a USAGE line without a help entry is a
|
||||
/// promise `help <verb>` breaks. The overview and each entry must also name the verb.
|
||||
#[test]
|
||||
fn every_usage_verb_has_help() {
|
||||
for verb in [
|
||||
"pair",
|
||||
"hosts",
|
||||
"wake",
|
||||
"library",
|
||||
"launch",
|
||||
"open",
|
||||
"reachable",
|
||||
"speed-test",
|
||||
"profiles",
|
||||
"reset",
|
||||
] {
|
||||
let h = verb_help(verb).unwrap_or_else(|| panic!("no help for {verb}"));
|
||||
assert!(
|
||||
h.starts_with(&format!("punktfunk {verb}")),
|
||||
"help for {verb} must lead with its own invocation"
|
||||
);
|
||||
assert!(USAGE.contains(verb), "USAGE must advertise {verb}");
|
||||
}
|
||||
assert!(verb_help("bogus").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_reads_the_argument_after_its_flag() {
|
||||
let a = argv(&["--game", "steam:570", "--exec"]);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Runs the REAL `punktfunk` binary and pins its self-documentation contract: help goes
|
||||
//! to stdout and exits 0, an unknown verb refuses on stderr with the not-found code.
|
||||
//!
|
||||
//! This exists so CI *executes* the shipped binary at least once per platform. A binary
|
||||
//! that compiles but is the wrong program passes every build/clippy/fmt gate — that is
|
||||
//! exactly how 0.22.0 shipped a stub as `punktfunk-session` — and only a gate that runs
|
||||
//! the thing catches the class. The help paths are the right probe: they touch no config
|
||||
//! stores and no network, so they are safe on any runner.
|
||||
#![cfg(any(target_os = "linux", windows))]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn punktfunk(args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_punktfunk"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run punktfunk")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_and_help_print_the_overview_on_stdout() {
|
||||
for args in [&[][..], &["help"][..], &["--help"][..], &["-h"][..]] {
|
||||
let out = punktfunk(args);
|
||||
assert!(out.status.success(), "{args:?} must exit 0");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("punktfunk pair"),
|
||||
"{args:?} overview lists the verbs"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("help <command>"),
|
||||
"{args:?} overview points at per-verb help"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_verb_help_answers_both_spellings() {
|
||||
for args in [
|
||||
&["help", "launch"][..],
|
||||
&["launch", "--help"][..],
|
||||
&["launch", "-h"][..],
|
||||
] {
|
||||
let out = punktfunk(args);
|
||||
assert!(out.status.success(), "{args:?} must exit 0");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
stdout.contains("--exec"),
|
||||
"{args:?} documents launch's flags"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_prints_the_crate_version() {
|
||||
let out = punktfunk(&["--version"]);
|
||||
assert!(out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains(env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_verbs_refuse_with_the_not_found_code() {
|
||||
let out = punktfunk(&["frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown verb exits 5");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command"));
|
||||
|
||||
let out = punktfunk(&["help", "frobnicate"]);
|
||||
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
|
||||
}
|
||||
@@ -122,6 +122,13 @@ struct Args {
|
||||
/// pointer-lock client expecting the HOST to composite the cursor into the video. Decode the
|
||||
/// dump and look for the pointer — a cursorless dump is the bug this flag was built to catch.
|
||||
cursor_capture: bool,
|
||||
/// `--cursor-nochannel` — the same relative wiggle WITHOUT the cursor channel: no
|
||||
/// `CLIENT_CAP_CURSOR`, no render-mode flip. The headless reproduction of a client LATCHED
|
||||
/// in capture mode at connect (`console.rs` `latched_mouse` — it never advertises the
|
||||
/// channel), the shape of the 2026-07 "no cursor in Mutter capture mode" field report. The
|
||||
/// host must composite the metadata cursor on its own; decode the dump and look for the
|
||||
/// pointer.
|
||||
cursor_nochannel: bool,
|
||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||
discover: Option<u64>,
|
||||
@@ -301,6 +308,7 @@ fn parse_args() -> Args {
|
||||
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
||||
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
|
||||
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,32 +853,37 @@ async fn session(args: Args) -> Result<()> {
|
||||
"SPEED TEST complete",
|
||||
);
|
||||
});
|
||||
} else if args.cursor_capture {
|
||||
// Capture-model cursor repro: flip the negotiated cursor channel to "host composites"
|
||||
// and drive RELATIVE pointer motion, exactly like a pointer-lock client. Owns BOTH
|
||||
// control halves: the host may send CursorShape (0x50) messages while the channel is
|
||||
} else if args.cursor_capture || args.cursor_nochannel {
|
||||
// Capture-model cursor repro. `--cursor-capture`: flip the negotiated cursor channel to
|
||||
// "host composites" and drive RELATIVE pointer motion, exactly like a pointer-lock
|
||||
// client. `--cursor-nochannel`: the same motion with NO channel at all (a
|
||||
// capture-latched client) — the host must composite unprompted. Owns BOTH control
|
||||
// halves: the host may send CursorShape (0x50) messages while a negotiated channel is
|
||||
// still in its initial client-draws state, and dropping our recv half would fail those
|
||||
// writes host-side.
|
||||
let flip_channel = args.cursor_capture;
|
||||
let mut cs = send;
|
||||
let mut cr = recv;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
match io::write_msg(
|
||||
&mut cs,
|
||||
&CursorRenderMode {
|
||||
client_draws: false,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!(
|
||||
"cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the host \
|
||||
must now composite the pointer into the video"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed");
|
||||
return;
|
||||
if flip_channel {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
match io::write_msg(
|
||||
&mut cs,
|
||||
&CursorRenderMode {
|
||||
client_draws: false,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!(
|
||||
"cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the \
|
||||
host must now composite the pointer into the video"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drain (and just log) whatever the host still sends on the control stream.
|
||||
|
||||
@@ -41,37 +41,16 @@ anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# The GTK4/libadwaita session shell (app.rs, cli.rs, ui_*.rs, shortcuts.rs, spawn.rs). Those
|
||||
# modules are `#[cfg(target_os = "linux")]` in main.rs, so their dependencies belong in a
|
||||
# linux-only block — the Windows leg of this crate is a stub binary and must not pull GTK.
|
||||
#
|
||||
# Versions and features track clients/linux verbatim (gtk4 0.11 "v4_16", libadwaita 0.9 "v1_5"):
|
||||
# the two crates compile the same widget vocabulary, and a version skew between them would show up
|
||||
# as type mismatches through relm4 rather than as anything legible.
|
||||
#
|
||||
# glib / gio / gdk / pango are NOT listed on purpose — the sources reach them as `gtk::glib`,
|
||||
# `gtk::gio`, `gtk::gdk` and `gtk::pango`, gtk4's own re-exports. Declaring them separately would
|
||||
# risk resolving a DIFFERENT version of the same sys crate than the one gtk4 links against.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
|
||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
relm4 = { version = "0.11", features = ["libadwaita"] }
|
||||
async-channel = "2"
|
||||
# NOT optional here, unlike the shared block above where it hangs off `ui`. cli.rs's `--json` host
|
||||
# listing is a CLI feature, not a console-UI one, so on Linux it is needed whether or not `ui` is
|
||||
# on — and `--no-default-features` is a documented Linux build ("same streaming, stats on stdout
|
||||
# only"), which without this simply did not compile.
|
||||
serde_json = "1"
|
||||
# This crate carries NO toolkit, deliberately: it is the renderer the shells spawn, and the
|
||||
# `--no-default-features` build is what a minimal/embedded image installs. GTK4/libadwaita/relm4
|
||||
# belong to `clients/linux` (the shell) and must never appear here — a stray copy of the shell's
|
||||
# modules landed in src/ once (b0ea1e6b) and dragging its dependencies in behind it is what let
|
||||
# the wrong `main.rs` build and ship as `punktfunk-session`.
|
||||
|
||||
# Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from
|
||||
# the SDK); same pattern as clients/windows.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
# Compiles data/ (the OS-mark symbolic icons) into the embedded gresource (build.rs) —
|
||||
# needs `glib-compile-resources`, which ships with the GTK dev stack this crate needs anyway.
|
||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||
glib-build-tools = "0.22"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -5,10 +5,15 @@ no widgets, terminal stats. The power-user / gamescope stream client, and the st
|
||||
presenter of the Linux client re-architecture (punktfunk-planning:
|
||||
`linux-client-rearchitecture.md`).
|
||||
|
||||
This binary is deliberately dumb: a renderer the front-ends call INTO — the GTK shell
|
||||
(`punktfunk-client`), the WinUI shell, and the `punktfunk` CLI all spawn it through the
|
||||
same brain (`pf_client_core::orchestrate`), which resolves policy (profiles, settings,
|
||||
wake) and hands the result down, normally as a `--resolved-spec` file. It reads the
|
||||
shared stores only as the compat fallback for a bare hand-launched invocation.
|
||||
|
||||
```
|
||||
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
|
||||
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
|
||||
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
|
||||
```
|
||||
|
||||
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
|
||||
@@ -21,12 +26,10 @@ Reads the same identity / known-hosts / settings stores as the desktop client
|
||||
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
|
||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||
|
||||
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
|
||||
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
|
||||
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
|
||||
label the host files this client under, defaulting to the hostname. It is in the
|
||||
`--no-default-features` build too: enrolling must never be the reason a minimal image has
|
||||
to pull in Skia.
|
||||
Pairing is `punktfunk pair <host>` — the CLI, which ships alongside this binary in every
|
||||
package and needs no window and no toolkit either. `punktfunk-session --pair` still works
|
||||
for one release (someone's provisioning script calls it today) but prints a deprecation
|
||||
notice: pairing is a trust ceremony and belongs to the brain, not a renderer.
|
||||
|
||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||
|
||||
@@ -4,17 +4,8 @@
|
||||
//! icon is the generic default).
|
||||
|
||||
fn main() {
|
||||
// Linux: compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic
|
||||
// icons) into a gresource bundle, registered at startup via
|
||||
// `gio::resources_register_include!`. Host-gated like the Windows leg below.
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
|
||||
glib_build_tools::compile_resources(
|
||||
&["data"],
|
||||
"data/resources.gresource.xml",
|
||||
"punktfunk-client.gresource",
|
||||
);
|
||||
}
|
||||
// No Linux leg: this binary carries no toolkit, so it has no gresource to compile. The
|
||||
// OS-mark icons belong to the GTK shell (`clients/linux/data`), which builds its own.
|
||||
|
||||
// cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary);
|
||||
// CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass).
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="#000000"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 635 B |
@@ -1,2 +0,0 @@
|
||||
<!-- arch — from Simple Icons (CC0 1.0), via react-icons SiArchlinux. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"/></svg>
|
||||
|
Before Width: | Height: | Size: 836 B |
@@ -1,2 +0,0 @@
|
||||
<!-- debian — from Simple Icons (CC0 1.0), via react-icons SiDebian. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- fedora — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaFedora. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- linux — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaLinux. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- nixos — from Simple Icons (CC0 1.0), via react-icons SiNixos. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 875 B |
@@ -1,2 +0,0 @@
|
||||
<!-- opensuse — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSuse. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="#000000"><path d="M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSteam. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 932 B |
@@ -1,2 +0,0 @@
|
||||
<!-- ubuntu — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaUbuntu. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,2 +0,0 @@
|
||||
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
||||
|
Before Width: | Height: | Size: 339 B |
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- The shell's embedded icon assets: the host-card OS marks (derived from the
|
||||
assets/os-icons masters; see that directory's README for provenance/licensing).
|
||||
Registered under the hicolor-style layout IconTheme::add_resource_path expects. -->
|
||||
<gresources>
|
||||
<gresource prefix="/io/unom/Punktfunk">
|
||||
<file>icons/scalable/actions/pf-os-windows-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-apple-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-linux-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-steam-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-ubuntu-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-fedora-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-arch-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-debian-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-nixos-symbolic.svg</file>
|
||||
<file>icons/scalable/actions/pf-os-opensuse-symbolic.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -1,699 +0,0 @@
|
||||
//! Command-line entry paths: argv helpers, the headless flows (pair/wake/library), the
|
||||
//! exec handoff to `punktfunk-session` for `--connect`/`--browse`, and the CI screenshot
|
||||
//! scenes.
|
||||
|
||||
use crate::app::AppModel;
|
||||
use crate::trust::{forget_placeholder, KnownHost, KnownHosts};
|
||||
use crate::ui_hosts::{ConnectRequest, HostsMsg};
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use relm4::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The handles `run_shot` needs — cloned out of `AppModel` before it moves into the
|
||||
/// component parts, so the scene can be dispatched from the window's `map` callback.
|
||||
pub struct ShotCtx {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub nav: adw::NavigationView,
|
||||
pub hosts: relm4::Sender<HostsMsg>,
|
||||
pub settings: Rc<RefCell<crate::trust::Settings>>,
|
||||
pub gamepad: crate::gamepad::GamepadService,
|
||||
pub identity: (String, String),
|
||||
pub sender: ComponentSender<AppModel>,
|
||||
}
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
pub fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
/// True if argv contains `flag` (a valueless switch).
|
||||
pub fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link
|
||||
/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what
|
||||
/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's
|
||||
/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser —
|
||||
/// this only decides whether argv contains something addressed to us.
|
||||
pub fn deep_link_arg() -> Option<String> {
|
||||
std::env::args().skip(1).find(|a| {
|
||||
let lower = a.to_ascii_lowercase();
|
||||
lower.starts_with("punktfunk://") || lower.starts_with("pf://")
|
||||
})
|
||||
}
|
||||
|
||||
/// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the
|
||||
/// console library exec the session binary, which handles its own fullscreen).
|
||||
pub fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Split `host[:port]`: no colon defaults the port to 9777; a colon with an unparsable
|
||||
/// port yields `None` for it (callers decide whether to default or bail).
|
||||
fn parse_host_port(target: &str) -> (String, Option<u16>) {
|
||||
match target.rsplit_once(':') {
|
||||
Some((a, p)) => (a.to_string(), p.parse().ok()),
|
||||
None => (target.to_string(), Some(9777)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--connect` / `--browse`: streams and the console library live in the
|
||||
/// `punktfunk-session` Vulkan binary — replace this process with it, forwarding the
|
||||
/// relevant argv verbatim. This keeps the Decky wrapper (which launches the SHELL with
|
||||
/// these flags) working unchanged until it's repointed at the session binary.
|
||||
pub fn exec_session() -> glib::ExitCode {
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
let forward = [
|
||||
"--connect",
|
||||
"--browse",
|
||||
"--fp",
|
||||
"--launch",
|
||||
"--mgmt",
|
||||
"--connect-timeout",
|
||||
];
|
||||
let mut cmd = std::process::Command::new(crate::spawn::session_binary());
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a == "--fullscreen" || a == "--stats" {
|
||||
cmd.arg(a);
|
||||
} else if forward.contains(&a.as_str()) {
|
||||
cmd.arg(&a);
|
||||
if let Some(v) = args.peek() {
|
||||
if !v.starts_with("--") {
|
||||
cmd.arg(args.next().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let err = cmd.exec(); // only returns on failure
|
||||
eprintln!("exec punktfunk-session: {err}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
|
||||
/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the
|
||||
/// known-hosts store as paired, so a later `--connect` connects silently. Same identity
|
||||
/// store the streaming path uses, so pairing here makes the stream work.
|
||||
/// Prints a one-line `paired <addr>:<port> fp=<hex>` on success; exits non-zero on failure.
|
||||
pub fn headless_pair(pin: &str) -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
// The label the HOST stores this client under (its paired-devices list).
|
||||
let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string());
|
||||
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match crate::trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = crate::trust::hex(&fp);
|
||||
crate::trust::persist_host(
|
||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
&addr,
|
||||
port,
|
||||
&fp_hex,
|
||||
true,
|
||||
);
|
||||
// A host manually added via `--add-host` (no fingerprint yet) is stored as an
|
||||
// addr-keyed placeholder; now that the ceremony yielded the real fingerprint,
|
||||
// `persist_host` created the fp-keyed entry — drop the placeholder so the list
|
||||
// shows this host once, not twice.
|
||||
forget_placeholder(&addr, port);
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"pairing failed: {} ({e:?})",
|
||||
crate::trust::pair_error_message(&e)
|
||||
);
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--wake host[:port]` — send a Wake-on-LAN magic packet to a saved host and exit,
|
||||
/// without opening a window. The MAC comes from the known-hosts store (learned from the
|
||||
/// host's mDNS `mac` TXT while it was online); exits non-zero if none is known yet.
|
||||
pub fn cli_wake() -> glib::ExitCode {
|
||||
let Some(target) = arg_value("--wake") else {
|
||||
eprintln!("--wake requires host[:port]");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let mac = crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
.map(|h| h.mac.clone())
|
||||
.unwrap_or_default();
|
||||
if mac.is_empty() {
|
||||
eprintln!(
|
||||
"--wake: no MAC known for {addr}:{port} — connect once while the host is awake so its \
|
||||
advertised MAC is learned"
|
||||
);
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
crate::wol::wake(&mac, addr.parse().ok());
|
||||
println!("woke {addr}:{port} ({} MAC(s) targeted)", mac.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `--library host[:mgmt_port]` — fetch and print the host's game library over the real
|
||||
/// mTLS + pinned-fingerprint client, no GTK window. The pin comes from `--fp HEX` when
|
||||
/// given, else the known-hosts store (matched by address), else none (TOFU-accept).
|
||||
pub fn headless_library(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = match target.rsplit_once(':') {
|
||||
Some((a, p)) if p.parse::<u16>().is_ok() => (a.to_string(), p.parse().unwrap()),
|
||||
_ => (target.to_string(), crate::library::DEFAULT_MGMT_PORT),
|
||||
};
|
||||
let identity = match crate::trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let pin = arg_value("--fp")
|
||||
.as_deref()
|
||||
.and_then(crate::trust::parse_hex32)
|
||||
.or_else(|| {
|
||||
crate::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr)
|
||||
.and_then(|h| crate::trust::parse_hex32(&h.fp_hex))
|
||||
});
|
||||
match crate::library::fetch_games(&addr, port, &identity, pin) {
|
||||
Ok(games) => {
|
||||
for g in &games {
|
||||
println!("{}\t{}\t{}", g.id, g.store, g.title);
|
||||
}
|
||||
println!("{} game(s)", games.len());
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("library: {e}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Headless host-store management — the shared known-hosts store (`client-known-hosts.json`)
|
||||
// is the SINGLE source of truth for every client on this device (the GTK shell, the Vulkan
|
||||
// session, and the Decky plugin, which shells out to these modes). Exposing add/edit/forget/
|
||||
// list/reset here lets the Decky Gaming-Mode UI mutate exactly the store the desktop client
|
||||
// reads, so a change in one surface shows up in the other. Reachability (`--list-hosts
|
||||
// --probe`, `--reachable`) answers "is this host online?" WITHOUT mDNS, so a host reached
|
||||
// over a routed network (Tailscale/VPN/another subnet) no longer reads as offline.
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
/// The per-probe budget: a cold host on a routed link answers in well under this, and every
|
||||
/// saved host is probed in parallel so the wall-clock cost is one timeout, not the sum.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
/// Selector for `--set-host`/`--forget-host`: a 64-hex fingerprint pins one entry across IP
|
||||
/// changes; anything else is treated as `addr[:port]` (manual entries have no fingerprint).
|
||||
enum Selector {
|
||||
Fp(String),
|
||||
Addr(String, u16),
|
||||
}
|
||||
|
||||
fn parse_selector(s: &str) -> Selector {
|
||||
if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Selector::Fp(s.to_lowercase())
|
||||
} else {
|
||||
let (addr, port) = parse_host_port(s);
|
||||
Selector::Addr(addr, port.unwrap_or(9777))
|
||||
}
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
fn matches(&self, h: &KnownHost) -> bool {
|
||||
match self {
|
||||
Selector::Fp(fp) => h.fp_hex.eq_ignore_ascii_case(fp),
|
||||
Selector::Addr(addr, port) => h.addr == *addr && h.port == *port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe every saved host for reachability in parallel (shared with the hosts-page presence pips).
|
||||
fn probe_all(hosts: &[KnownHost]) -> Vec<bool> {
|
||||
crate::trust::probe_reachable_many(
|
||||
hosts.iter().map(|h| (h.addr.clone(), h.port)).collect(),
|
||||
PROBE_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
/// `--list-hosts [--probe]` — the saved known-hosts store as JSON (the store the Decky plugin
|
||||
/// renders). With `--probe`, each host carries an `online` bool from a live reachability probe
|
||||
/// (mDNS-independent); without it, `online` is `null` (unknown — the caller falls back to its
|
||||
/// own mDNS view). Shape: `{"hosts":[{name,addr,port,fp_hex,paired,mac,last_used,online}]}`.
|
||||
pub fn headless_list_hosts() -> glib::ExitCode {
|
||||
let known = KnownHosts::load();
|
||||
let online: Option<Vec<bool>> = arg_flag("--probe").then(|| probe_all(&known.hosts));
|
||||
let hosts: Vec<serde_json::Value> = known
|
||||
.hosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| {
|
||||
serde_json::json!({
|
||||
"name": h.name,
|
||||
"addr": h.addr,
|
||||
"port": h.port,
|
||||
"fp_hex": h.fp_hex,
|
||||
"paired": h.paired,
|
||||
"mac": h.mac,
|
||||
"os": h.os,
|
||||
"last_used": h.last_used,
|
||||
"online": online.as_ref().map(|v| serde_json::Value::Bool(v[i]))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
match serde_json::to_string(&serde_json::json!({ "hosts": hosts })) {
|
||||
Ok(s) => {
|
||||
println!("{s}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("list-hosts: {e}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--reachable host[:port]` — probe one target and exit 0 (reachable) / 1 (not). A cheap
|
||||
/// "is it online / test this address" check that never touches mDNS.
|
||||
pub fn headless_reachable(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = parse_host_port(target);
|
||||
let port = port.unwrap_or(9777);
|
||||
if NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
|
||||
println!("reachable {addr}:{port}");
|
||||
glib::ExitCode::SUCCESS
|
||||
} else {
|
||||
eprintln!("unreachable {addr}:{port}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
/// `--add-host host[:port] [--host-label NAME] [--fp HEX]` — save a host by address so it can
|
||||
/// be paired/streamed even when mDNS never sees it (a Tailscale/VPN box). Without `--fp` the
|
||||
/// entry is an unpaired placeholder (keyed by address) the user pairs later — `headless_pair`
|
||||
/// then replaces it with the fingerprinted entry. With `--fp` (e.g. carried from an advert)
|
||||
/// it is pinned immediately as trusted-but-not-PIN-paired. Prints `added <addr>:<port>`.
|
||||
pub fn headless_add_host(target: &str) -> glib::ExitCode {
|
||||
let (addr, port) = parse_host_port(target);
|
||||
let port = port.unwrap_or(9777);
|
||||
let name = arg_value("--host-label")
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| addr.clone());
|
||||
if let Some(fp_hex) = arg_value("--fp").filter(|f| crate::trust::parse_hex32(f).is_some()) {
|
||||
// Fingerprint known up front: upsert the pinned entry (paired stays false — no PIN
|
||||
// ceremony happened; a later `--pair` upgrades it).
|
||||
crate::trust::persist_host(&name, &addr, port, &fp_hex.to_lowercase(), false);
|
||||
forget_placeholder(&addr, port);
|
||||
println!("added {addr}:{port} fp={}", fp_hex.to_lowercase());
|
||||
return glib::ExitCode::SUCCESS;
|
||||
}
|
||||
// No fingerprint yet — an address-keyed placeholder. Refresh the name if it already exists.
|
||||
let mut known = KnownHosts::load();
|
||||
if let Some(h) = known
|
||||
.hosts
|
||||
.iter_mut()
|
||||
.find(|h| h.addr == addr && h.port == port)
|
||||
{
|
||||
h.name = name;
|
||||
} else {
|
||||
known.hosts.push(KnownHost {
|
||||
name,
|
||||
addr: addr.clone(),
|
||||
port,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
match known.save() {
|
||||
Ok(()) => {
|
||||
println!("added {addr}:{port}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("add-host: {e:#}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--set-host <fp|host[:port]> [--host-label NAME] [--addr ADDR] [--port PORT]` — edit a saved
|
||||
/// host: rename and/or re-point its address. Identified by fingerprint (survives IP changes) or
|
||||
/// current address. Prints `updated <name>`; fails if nothing matched.
|
||||
pub fn headless_set_host(selector: &str) -> glib::ExitCode {
|
||||
let sel = parse_selector(selector);
|
||||
let mut known = KnownHosts::load();
|
||||
let Some(h) = known.hosts.iter_mut().find(|h| sel.matches(h)) else {
|
||||
eprintln!("set-host: no saved host matches {selector:?}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
if let Some(name) = arg_value("--host-label").map(|n| n.trim().to_string()) {
|
||||
if !name.is_empty() {
|
||||
h.name = name;
|
||||
}
|
||||
}
|
||||
if let Some(addr) = arg_value("--addr").map(|a| a.trim().to_string()) {
|
||||
if !addr.is_empty() {
|
||||
h.addr = addr;
|
||||
}
|
||||
}
|
||||
if let Some(port) = arg_value("--port").and_then(|p| p.trim().parse::<u16>().ok()) {
|
||||
h.port = port;
|
||||
}
|
||||
let label = h.name.clone();
|
||||
match known.save() {
|
||||
Ok(()) => {
|
||||
println!("updated {label}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("set-host: {e:#}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--forget-host <fp|host[:port]>` — remove a saved host (drops the pinned fingerprint; a later
|
||||
/// connect must re-pair/trust). Prints `forgot N`; succeeds even if nothing matched (idempotent).
|
||||
pub fn headless_forget_host(selector: &str) -> glib::ExitCode {
|
||||
let sel = parse_selector(selector);
|
||||
let mut known = KnownHosts::load();
|
||||
let before = known.hosts.len();
|
||||
known.hosts.retain(|h| !sel.matches(h));
|
||||
let removed = before - known.hosts.len();
|
||||
if removed > 0 {
|
||||
if let Err(e) = known.save() {
|
||||
eprintln!("forget-host: {e:#}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
println!("forgot {removed}");
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// `--reset` — clear this device's client state: the saved known-hosts and the stream
|
||||
/// settings. The persistent IDENTITY (`client-cert.pem`/`client-key.pem`) is deliberately
|
||||
/// KEPT so the box isn't seen as a brand-new device everywhere (a re-pair still re-adds hosts);
|
||||
/// a caller wanting a true factory reset removes those separately. Missing files are fine.
|
||||
pub fn headless_reset() -> glib::ExitCode {
|
||||
let Ok(dir) = crate::trust::config_dir() else {
|
||||
eprintln!("reset: could not resolve config dir (HOME unset?)");
|
||||
return glib::ExitCode::FAILURE;
|
||||
};
|
||||
let mut ok = true;
|
||||
for name in ["client-known-hosts.json", "client-gtk-settings.json"] {
|
||||
match std::fs::remove_file(dir.join(name)) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
eprintln!("reset: {name}: {e}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
println!("reset");
|
||||
glib::ExitCode::SUCCESS
|
||||
} else {
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
|
||||
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
|
||||
/// dispatch in `app.rs` is a single line.
|
||||
pub fn headless_host_command() -> Option<glib::ExitCode> {
|
||||
if arg_flag("--list-hosts") {
|
||||
return Some(headless_list_hosts());
|
||||
}
|
||||
if let Some(t) = arg_value("--reachable") {
|
||||
return Some(headless_reachable(&t));
|
||||
}
|
||||
if let Some(t) = arg_value("--add-host") {
|
||||
return Some(headless_add_host(&t));
|
||||
}
|
||||
if let Some(s) = arg_value("--set-host") {
|
||||
return Some(headless_set_host(&s));
|
||||
}
|
||||
if let Some(s) = arg_value("--forget-host") {
|
||||
return Some(headless_forget_host(&s));
|
||||
}
|
||||
if arg_flag("--reset") {
|
||||
return Some(headless_reset());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `PUNKTFUNK_SHOT_SCENE`, when set, selects a scripted host-free scene for CI screenshots.
|
||||
pub fn shot_scene() -> Option<String> {
|
||||
std::env::var("PUNKTFUNK_SHOT_SCENE")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Render one mock-populated, host-free scene over the already-presented window, then
|
||||
/// print `PF_SHOT_READY` once it has settled. When `PUNKTFUNK_SHOT_OUT=/path.png` is set
|
||||
/// the app CAPTURES ITSELF (widget snapshot → gsk render → PNG) — no Xvfb/ImageMagick
|
||||
/// needed. The stream and gamepad-library scenes are gone with the pages (both live in
|
||||
/// the session binary now).
|
||||
pub fn run_shot(ctx: &ShotCtx, scene: &str) {
|
||||
let sender = &ctx.sender;
|
||||
// A plausible host for the trust/pair dialogs (fp_hex = 64 hex chars).
|
||||
let mock_req = || ConnectRequest {
|
||||
name: "Living Room PC".to_string(),
|
||||
addr: "192.168.1.42".to_string(),
|
||||
port: 9777,
|
||||
fp_hex: Some(
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00".to_string(),
|
||||
),
|
||||
pair_optional: true,
|
||||
launch: None,
|
||||
mac: Vec::new(),
|
||||
profile: None,
|
||||
};
|
||||
let mock_advert =
|
||||
|key: &str, name: &str, addr: &str, fp: &str| crate::discovery::DiscoveredHost {
|
||||
key: key.to_string(),
|
||||
fullname: format!("{key}._punktfunk._udp.local."),
|
||||
name: name.to_string(),
|
||||
addr: addr.to_string(),
|
||||
port: 9777,
|
||||
fp_hex: fp.to_string(),
|
||||
pair: "required".to_string(),
|
||||
mgmt_port: None,
|
||||
mac: Vec::new(),
|
||||
os: "linux/arch/steamos".to_string(),
|
||||
};
|
||||
|
||||
// What the self-capture renders: the main window, except for scenes that open their
|
||||
// own toplevel (the shortcuts window).
|
||||
let mut target: gtk::Widget = ctx.window.clone().upcast();
|
||||
let hosts = &ctx.hosts;
|
||||
match scene {
|
||||
// Saved hosts come from the seeded known-hosts store; on top, inject synthetic
|
||||
// adverts through the same path the mDNS stream feeds.
|
||||
"hosts" | "02-hosts" => {
|
||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||
"mock-online",
|
||||
"Living Room PC",
|
||||
"192.168.1.42",
|
||||
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
|
||||
)));
|
||||
let _ = hosts.send(HostsMsg::Advert(mock_advert(
|
||||
"mock-new",
|
||||
"steamdeck",
|
||||
"192.168.1.77",
|
||||
"00aabbccddeeff112233445566778899a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||
)));
|
||||
}
|
||||
"settings" | "03-settings" => {
|
||||
// Mock devices so the shot shows the probe-dependent pickers populated.
|
||||
let dev = |name: &str, description: &str| pf_client_core::audio::AudioDevice {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
};
|
||||
let probes = crate::ui_settings::DeviceProbes {
|
||||
adapters: vec![
|
||||
"NVIDIA GeForce RTX 4070".to_string(),
|
||||
"AMD Radeon 780M".to_string(),
|
||||
],
|
||||
speakers: vec![dev("alsa_output.mock-hdmi", "HDMI / DisplayPort Audio")],
|
||||
mics: vec![dev("alsa_input.mock-usb", "USB Microphone Analog Stereo")],
|
||||
};
|
||||
// `PUNKTFUNK_SHOT_SETTINGS_SCOPE=<profile id|name>` captures the dialog in
|
||||
// PROFILE scope — the second half of the settings surface (design
|
||||
// client-settings-profiles.md §5.1), where only profileable rows render.
|
||||
let scope = std::env::var("PUNKTFUNK_SHOT_SETTINGS_SCOPE")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.and_then(|reference| {
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.resolve(&reference)
|
||||
.0
|
||||
.map(|p| crate::ui_settings::Scope::Profile(p.id.clone()))
|
||||
})
|
||||
.unwrap_or(crate::ui_settings::Scope::Defaults);
|
||||
let dialog = crate::ui_settings::show_scoped(
|
||||
&ctx.window,
|
||||
ctx.settings.clone(),
|
||||
&ctx.gamepad,
|
||||
&probes,
|
||||
scope,
|
||||
|_| {},
|
||||
|| {},
|
||||
);
|
||||
// Optional page for the capture (general/display/input/audio/controllers);
|
||||
// the dialog opens on General otherwise.
|
||||
if let Ok(page) = std::env::var("PUNKTFUNK_SHOT_SETTINGS_PAGE") {
|
||||
if !page.is_empty() {
|
||||
use adw::prelude::PreferencesDialogExt as _;
|
||||
dialog.set_visible_page_name(&page);
|
||||
}
|
||||
}
|
||||
}
|
||||
"trust" | "04-trust" => crate::ui_trust::tofu_dialog(&ctx.window, sender, mock_req()),
|
||||
"pair" | "05-pair" => {
|
||||
crate::ui_trust::pin_dialog(&ctx.window, sender, ctx.identity.clone(), mock_req())
|
||||
}
|
||||
"addhost" | "06-addhost" => {
|
||||
let _ = hosts.send(HostsMsg::ShowAddHost);
|
||||
}
|
||||
"shortcuts" | "07-shortcuts" => {
|
||||
let w = crate::app::shortcuts_window(&ctx.window);
|
||||
w.present();
|
||||
target = w.upcast();
|
||||
}
|
||||
// The library page with injected entries: mixed stores exercising the badge set,
|
||||
// no-art placeholders, and one solid-color texture standing in for a poster.
|
||||
"library" | "08-library" => {
|
||||
let (games, art) = mock_library();
|
||||
crate::ui_library::open_mock(
|
||||
&ctx.nav,
|
||||
ctx.identity.clone(),
|
||||
sender,
|
||||
mock_req(),
|
||||
games,
|
||||
art,
|
||||
);
|
||||
}
|
||||
other => tracing::warn!("unknown PUNKTFUNK_SHOT_SCENE={other:?}; showing hosts only"),
|
||||
}
|
||||
|
||||
let settle_ms = std::env::var("PUNKTFUNK_SHOT_SETTLE_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(900);
|
||||
let scene = scene.to_string();
|
||||
glib::timeout_add_local_once(std::time::Duration::from_millis(settle_ms), move || {
|
||||
use std::io::Write as _;
|
||||
// Self-capture of the dialog scenes (trust/pair/settings/addhost) needs a GL
|
||||
// renderer: `WidgetPaintable(window)` under the cairo software renderer doesn't
|
||||
// composite the `AdwDialog` overlay layer (the dialog IS presented — the
|
||||
// page-content scenes capture fine either way; CI uses GL or the X11 root-grab).
|
||||
let self_capture = std::env::var("PUNKTFUNK_SHOT_OUT")
|
||||
.ok()
|
||||
.filter(|p| !p.is_empty());
|
||||
if let Some(out) = &self_capture {
|
||||
if let Err(e) = save_png(&target, out) {
|
||||
eprintln!("PF_SHOT_ERROR scene={scene}: {e:#}");
|
||||
}
|
||||
}
|
||||
println!("PF_SHOT_READY scene={scene}");
|
||||
let _ = std::io::stdout().flush();
|
||||
// Self-capture mode: the shot is on disk — exit so back-to-back scene runs
|
||||
// don't stack windows on a live desktop.
|
||||
if self_capture.is_some() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The mock game set for the `library` scene: mixed stores exercising the badge set,
|
||||
/// plus one solid-colour poster texture.
|
||||
fn mock_library() -> (
|
||||
Vec<crate::library::GameEntry>,
|
||||
Vec<(String, gtk::gdk::Texture)>,
|
||||
) {
|
||||
let game = |id: &str, store: &str, title: &str| crate::library::GameEntry {
|
||||
id: id.to_string(),
|
||||
store: store.to_string(),
|
||||
title: title.to_string(),
|
||||
art: crate::library::Artwork::default(),
|
||||
platform: None,
|
||||
};
|
||||
let games = vec![
|
||||
game("steam:570", "steam", "Dota 2"),
|
||||
game("steam:1091500", "steam", "Cyberpunk 2077"),
|
||||
game("custom:emu-1", "custom", "RetroArch"),
|
||||
game("heroic:fortnite", "heroic", "Fortnite"),
|
||||
game("gog:witcher3", "gog", "The Witcher 3"),
|
||||
game("lutris:osu", "lutris", "osu!"),
|
||||
];
|
||||
let art = vec![(
|
||||
"steam:570".to_string(),
|
||||
solid_texture(300, 450, 0x35, 0x84, 0xe4),
|
||||
)];
|
||||
(games, art)
|
||||
}
|
||||
|
||||
/// A WxH single-colour RGBA texture — the `library` scene's stand-in for a fetched poster.
|
||||
fn solid_texture(w: i32, h: i32, r: u8, g: u8, b: u8) -> gtk::gdk::Texture {
|
||||
let px = [r, g, b, 0xff].repeat((w * h) as usize);
|
||||
gtk::gdk::MemoryTexture::new(
|
||||
w,
|
||||
h,
|
||||
gtk::gdk::MemoryFormat::R8g8b8a8,
|
||||
&glib::Bytes::from_owned(px),
|
||||
(w * 4) as usize,
|
||||
)
|
||||
.upcast()
|
||||
}
|
||||
|
||||
/// Snapshot `widget` (the whole window, dialogs included) into a PNG: WidgetPaintable →
|
||||
/// `gtk::Snapshot` → the realized native's gsk renderer → `GdkTexture::save_to_png`.
|
||||
fn save_png(widget: >k::Widget, path: &str) -> anyhow::Result<()> {
|
||||
use anyhow::Context as _;
|
||||
let (w, h) = (widget.width(), widget.height());
|
||||
anyhow::ensure!(w > 0 && h > 0, "widget not laid out yet ({w}x{h})");
|
||||
let paintable = gtk::WidgetPaintable::new(Some(widget));
|
||||
let snapshot = gtk::Snapshot::new();
|
||||
paintable.snapshot(&snapshot, f64::from(w), f64::from(h));
|
||||
let node = snapshot.to_node().context("empty snapshot")?;
|
||||
let renderer = widget
|
||||
.native()
|
||||
.context("widget not realized")?
|
||||
.renderer()
|
||||
.context("no gsk renderer")?;
|
||||
let texture = renderer.render_texture(node, None);
|
||||
texture
|
||||
.save_to_png(path)
|
||||
.with_context(|| format!("save {path}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,42 +1,645 @@
|
||||
//! `punktfunk-client` — the native Linux punktfunk/1 desktop shell (relm4/libadwaita).
|
||||
//! `punktfunk-session` — the Vulkan session binary (punktfunk-planning
|
||||
//! `linux-client-rearchitecture.md`, Phase 1: the software-path presenter MVP, which IS
|
||||
//! the power-user CLI build).
|
||||
//!
|
||||
//! Hosts, pairing/trust, settings, and the desktop library page; every stream (and the
|
||||
//! console game library) runs in the spawned `punktfunk-session` Vulkan binary — the
|
||||
//! shell never touches video (punktfunk-planning `linux-client-rearchitecture.md`).
|
||||
//! One stream session per invocation: `--connect host[:port]` (+ `--fp HEX`,
|
||||
//! `--launch id`, `--fullscreen`), exits when the session ends. Reads the same identity
|
||||
//! / known-hosts / settings stores as the desktop shell on each OS — the GTK client
|
||||
//! (`punktfunk-client`) on Linux, the WinUI client on Windows — so pairing on either side
|
||||
//! makes the other connect silently. `--pair <PIN> --connect host` runs the ceremony here,
|
||||
//! with no window and no toolkit, for machines that have only a shell.
|
||||
//!
|
||||
//! Stdout is the machine interface (the shell↔session contract): `{"ready":true}` after
|
||||
//! the first presented frame, `stats:` lines per 1 s window, one `{"error": …}` /
|
||||
//! `{"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: 0 clean end,
|
||||
//! 2 connect failed, 3 trust rejected / pairing required, 4 presenter init failed.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
// The UI-agnostic plumbing lives in `pf-client-core`, shared with the session binary.
|
||||
// Root re-exports keep every `crate::trust`-style path resolving unchanged.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pf_client_core::{discovery, gamepad, library, os, trust, video, wol};
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod app;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cli;
|
||||
// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5).
|
||||
#[cfg(target_os = "linux")]
|
||||
mod shortcuts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spawn;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_hosts;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_library;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_settings;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ui_trust;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod session_main {
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::SessionParams;
|
||||
use pf_client_core::trust;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> gtk::glib::ExitCode {
|
||||
app::run()
|
||||
pub const EXIT_CONNECT_FAILED: u8 = 2;
|
||||
pub const EXIT_TRUST_REJECTED: u8 = 3;
|
||||
pub const EXIT_PRESENTER_FAILED: u8 = 4;
|
||||
|
||||
/// The value following `flag` in argv, if present (`--flag value`).
|
||||
pub(crate) fn arg_value(flag: &str) -> Option<String> {
|
||||
std::env::args()
|
||||
.skip_while(|a| a != flag)
|
||||
.nth(1)
|
||||
.filter(|v| !v.starts_with("--"))
|
||||
}
|
||||
|
||||
pub(crate) fn arg_flag(flag: &str) -> bool {
|
||||
std::env::args().any(|a| a == flag)
|
||||
}
|
||||
|
||||
/// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a
|
||||
/// manual launch under Gaming Mode does the right thing too. (Browse-mode only —
|
||||
/// gated with `mod browse`, its one caller.)
|
||||
#[cfg(feature = "ui")]
|
||||
pub(crate) fn fullscreen_mode() -> bool {
|
||||
arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning
|
||||
/// shell passes its own position so the session opens on the same monitor); absent or
|
||||
/// unparsable = centered on the primary display.
|
||||
pub(crate) fn window_pos() -> Option<(i32, i32)> {
|
||||
let v = arg_value("--window-pos")?;
|
||||
let (x, y) = v.split_once(',')?;
|
||||
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
/// `--pair <PIN> --connect host[:port]` — the SPAKE2 PIN ceremony with no window, no GTK
|
||||
/// and no console UI, so a machine that has only SSH can be enrolled: an embedded/kiosk
|
||||
/// client, a headless box, an image being provisioned. Writes the verified host into the
|
||||
/// same known-hosts store `--connect` reads, so pairing here is exactly what makes the
|
||||
/// later stream connect silently.
|
||||
///
|
||||
/// Deliberately identical in shape and output to `punktfunk-client --pair` (which stays
|
||||
/// the desktop route) — the difference is only that this binary carries no toolkit, so it
|
||||
/// is the one a minimal image installs. Present in the `--no-default-features` build too:
|
||||
/// enrolment must not be the reason an embedded image has to pull in Skia.
|
||||
fn headless_pair(pin: &str) -> u8 {
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!("--pair requires --connect host[:port]");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
// The label the HOST files this client under. A headless box has nobody to ask, so
|
||||
// the hostname is the only name that will mean anything in the paired-devices list.
|
||||
let name = arg_value("--name").unwrap_or_else(trust::device_name);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("client identity: {e:#}");
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
match trust::pair_with_host(&addr, port, &identity, pin, &name) {
|
||||
Ok(fp) => {
|
||||
let fp_hex = trust::hex(&fp);
|
||||
trust::persist_host(
|
||||
&arg_value("--host-label").unwrap_or_else(|| addr.clone()),
|
||||
&addr,
|
||||
port,
|
||||
&fp_hex,
|
||||
true,
|
||||
);
|
||||
trust::forget_placeholder(&addr, port);
|
||||
println!("paired {addr}:{port} fp={fp_hex}");
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("pairing failed: {} ({e:?})", trust::pair_error_message(&e));
|
||||
EXIT_TRUST_REJECTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `host[:port]`, port defaulting to the native 9777.
|
||||
pub(crate) fn parse_host_port(target: &str) -> (String, u16) {
|
||||
match target.rsplit_once(':') {
|
||||
Some((a, p)) => match p.parse() {
|
||||
Ok(port) => (a.to_string(), port),
|
||||
Err(_) => {
|
||||
eprintln!("unparsable port in '{target}', using default 9777");
|
||||
(a.to_string(), 9777)
|
||||
}
|
||||
},
|
||||
None => (target.to_string(), 9777),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--profile <id|name>` — the settings profile this one session runs with, overriding the
|
||||
/// host's own binding for this launch only (never rebinding it): the shells' "Connect
|
||||
/// with ▸ X" and a `punktfunk://…&profile=` link both land here. Absent = honor the host's
|
||||
/// binding; `--profile ""` (or a bare `--profile`) forces the global defaults, which is
|
||||
/// how "Connect with ▸ Default settings" reaches a bound host.
|
||||
fn profile_arg() -> Option<String> {
|
||||
arg_flag("--profile").then(|| arg_value("--profile").unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The connect budget: 15 s normally; `--connect-timeout SECS` overrides — the
|
||||
/// shell's request-access flow passes ~185 s because the host PARKS the connection
|
||||
/// until the operator clicks Approve.
|
||||
pub(crate) fn connect_timeout() -> Duration {
|
||||
Duration::from_secs(
|
||||
arg_value("--connect-timeout")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(15),
|
||||
)
|
||||
}
|
||||
|
||||
/// One session's pump parameters from the EFFECTIVE settings — shared by `--connect`
|
||||
/// and every `--browse` launch. Explicit settings, `0` fields resolved to the
|
||||
/// window's display (the GTK client reads the monitor under its window — same
|
||||
/// contract).
|
||||
///
|
||||
/// `settings` is what [`trust::effective_settings`] returned, never a raw
|
||||
/// `Settings::load()`: both callers resolve the host's profile first, so the two
|
||||
/// construction sites cannot drift (they historically did — touching one and not the
|
||||
/// other is a Windows-only build break). `profile` is that profile's name, for the
|
||||
/// stats overlay's first line.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn session_params(
|
||||
settings: &trust::Settings,
|
||||
profile: Option<String>,
|
||||
clipboard_override: Option<bool>,
|
||||
addr: String,
|
||||
port: u16,
|
||||
pin: [u8; 32],
|
||||
identity: (String, String),
|
||||
launch: Option<String>,
|
||||
gamepad: &GamepadService,
|
||||
native: Mode,
|
||||
force_software: Arc<AtomicBool>,
|
||||
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
|
||||
) -> SessionParams {
|
||||
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3). In spec
|
||||
// mode the spawner already resolved it; otherwise this looks it up itself, which is
|
||||
// the last store read the compat path still owes. `addr` is moved into the struct
|
||||
// below, so read it first.
|
||||
let clipboard = clipboard_override.unwrap_or_else(|| {
|
||||
trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync)
|
||||
});
|
||||
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
|
||||
// key) to OUR gamepad service — the shells' in-process services can't reach this
|
||||
// process. Applied per params-build (idempotent; browse re-launches included) so
|
||||
// it lands before the session attaches. Empty = automatic (most recent).
|
||||
if !settings.forward_pad.is_empty() {
|
||||
gamepad.set_pinned(Some(settings.forward_pad.clone()));
|
||||
}
|
||||
let mode = Mode {
|
||||
width: if settings.width == 0 {
|
||||
native.width
|
||||
} else {
|
||||
settings.width
|
||||
},
|
||||
height: if settings.width == 0 {
|
||||
native.height
|
||||
} else {
|
||||
settings.height
|
||||
},
|
||||
refresh_hz: if settings.refresh_hz == 0 {
|
||||
native.refresh_hz.max(30)
|
||||
} else {
|
||||
settings.refresh_hz
|
||||
},
|
||||
};
|
||||
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
|
||||
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
|
||||
// Native/explicit resolution so it composes uniformly with both.
|
||||
let (sw, sh) = punktfunk_core::render_scale::apply(
|
||||
mode.width,
|
||||
mode.height,
|
||||
settings.render_scale,
|
||||
punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
);
|
||||
let mode = Mode {
|
||||
width: sw,
|
||||
height: sh,
|
||||
..mode
|
||||
};
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
mode,
|
||||
compositor: CompositorPref::from_name(&settings.compositor)
|
||||
.unwrap_or(CompositorPref::Auto),
|
||||
gamepad: {
|
||||
// The setting AS CHOSEN goes to the pad service too, not just the Hello: the host
|
||||
// builds each virtual pad from that pad's arrival and only falls back to this
|
||||
// session default for a pad that never declares one, so an explicit choice that
|
||||
// stopped here would be undone the moment a controller connected.
|
||||
let chosen = GamepadPref::from_name(&settings.gamepad).unwrap_or(GamepadPref::Auto);
|
||||
gamepad.set_kind_override(chosen);
|
||||
match chosen {
|
||||
GamepadPref::Auto => gamepad.auto_pref(),
|
||||
explicit => explicit,
|
||||
}
|
||||
},
|
||||
bitrate_kbps: settings.bitrate_kbps,
|
||||
audio_channels: settings.audio_channels,
|
||||
preferred_codec: settings.preferred_codec(),
|
||||
// HDR off = don't advertise 10-bit/HDR at all; the host then never upgrades.
|
||||
video_caps: if settings.hdr_enabled {
|
||||
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// No portable Wayland/X11 display-volume query yet, so the host keeps its EDID
|
||||
// defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session
|
||||
// pump) pins one manually.
|
||||
display_hdr: None,
|
||||
// The presenter renders the host cursor locally in desktop mouse mode (M2 cursor
|
||||
// channel); capture-mode sessions keep the composited cursor, so only advertise
|
||||
// when the session STARTS in desktop mode. The host gates further (Linux portal
|
||||
// compositors only).
|
||||
cursor_forward: settings.mouse_mode() == trust::MouseMode::Desktop,
|
||||
mic_enabled: settings.mic_enabled,
|
||||
clipboard,
|
||||
// The Settings preference (auto → VAAPI where it exists; the presenter
|
||||
// demotes to software on boxes whose Vulkan can't import the dmabufs).
|
||||
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
|
||||
decoder: settings.decoder.clone(),
|
||||
launch,
|
||||
vulkan,
|
||||
pin: Some(pin),
|
||||
identity,
|
||||
connect_timeout: connect_timeout(),
|
||||
force_software,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's starting size under Match-window: the persisted last size, so the
|
||||
/// first connect's mode already matches the glass; `None` (policy off / never
|
||||
/// stored) = the 1280×720 default.
|
||||
pub(crate) fn window_size(settings: &trust::Settings) -> Option<(u32, u32)> {
|
||||
(settings.match_window && settings.last_window_w > 0 && settings.last_window_h > 0)
|
||||
.then_some((settings.last_window_w, settings.last_window_h))
|
||||
}
|
||||
|
||||
/// The Match-window policy hook for the presenter loop
|
||||
/// (design/midstream-resolution-resize.md D1/D2): `Some(persist)` turns the
|
||||
/// debounced resize→`Reconfigure` machinery on; the callback stores each resize-end's
|
||||
/// logical window size (load-modify-save, like the console settings screen) so the
|
||||
/// next launch opens at it.
|
||||
/// The Match-window policy hook (design/midstream-resolution-resize.md D1/D2). The
|
||||
/// callback used to load-modify-save the shared settings file from inside the renderer —
|
||||
/// one of that file's five concurrent writers, for a value only the parent needs. It now
|
||||
/// REPORTS the size on stdout and the spawner persists it
|
||||
/// (design/client-architecture-split.md §5).
|
||||
///
|
||||
/// `persist_locally` keeps a hand-run session remembering its own window: nobody is
|
||||
/// listening to stdout there, so the event alone would drop the value. A spawned session
|
||||
/// leaves the write to its parent, which is the whole point.
|
||||
pub(crate) fn match_window(
|
||||
settings: &trust::Settings,
|
||||
persist_locally: bool,
|
||||
) -> Option<Box<dyn FnMut(u32, u32)>> {
|
||||
settings.match_window.then(|| {
|
||||
Box::new(move |w: u32, h: u32| {
|
||||
println!("{{\"window\":{{\"w\":{w},\"h\":{h}}}}}");
|
||||
if persist_locally {
|
||||
pf_client_core::orchestrate::persist_window_size(w, h);
|
||||
}
|
||||
}) as Box<dyn FnMut(u32, u32)>
|
||||
})
|
||||
}
|
||||
|
||||
/// One JSON status line on stdout (the shell parses these; strings hand-escaped via
|
||||
/// the minimal rules a reason string can need). `pub(crate)`: browse mode emits its
|
||||
/// failure through the same contract when spawned with `--json-status`.
|
||||
pub(crate) fn json_line(key: &str, msg: &str, trust_rejected: Option<bool>) {
|
||||
let escaped: String = msg
|
||||
.chars()
|
||||
.flat_map(|c| match c {
|
||||
'"' => vec!['\\', '"'],
|
||||
'\\' => vec!['\\', '\\'],
|
||||
'\n' => vec!['\\', 'n'],
|
||||
c if (c as u32) < 0x20 => vec![' '],
|
||||
c => vec![c],
|
||||
})
|
||||
.collect();
|
||||
match trust_rejected {
|
||||
Some(t) => println!("{{\"{key}\":\"{escaped}\",\"trust_rejected\":{t}}}"),
|
||||
None => println!("{{\"{key}\":\"{escaped}\"}}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Steam Deck / RADV: Mesa gates Vulkan Video decode — the `VK_KHR_video_decode_*`
|
||||
/// extensions AND the decode-capable queue family — behind `RADV_PERFTEST=video_decode`.
|
||||
/// Without it the presenter's device advertises no decode queue, so `Decoder::new`'s
|
||||
/// `auto` path can't build the Vulkan decoder and the session silently falls back to
|
||||
/// VAAPI (whose separate-plane dmabuf import shows chroma fringing — green/yellow specks
|
||||
/// around the cursor — on VanGogh). We want the Vulkan path, so opt in here, before the
|
||||
/// RADV driver loads (the Vulkan instance is created later, inside `run_session`).
|
||||
///
|
||||
/// RADV-only knob: ANV/NVIDIA/other drivers ignore `RADV_PERFTEST`, and a box where video
|
||||
/// decode is already the default just no-ops. Append rather than clobber so a user's own
|
||||
/// `RADV_PERFTEST` survives; `PUNKTFUNK_DECODER=vaapi` still overrides the decoder choice.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn enable_radv_video_decode() {
|
||||
const TOKEN: &str = "video_decode";
|
||||
match std::env::var("RADV_PERFTEST") {
|
||||
Ok(v) if v.split(',').any(|t| t == TOKEN) => return,
|
||||
Ok(v) if !v.is_empty() => std::env::set_var("RADV_PERFTEST", format!("{v},{TOKEN}")),
|
||||
_ => std::env::set_var("RADV_PERFTEST", TOKEN),
|
||||
}
|
||||
tracing::info!(
|
||||
radv_perftest = %std::env::var("RADV_PERFTEST").unwrap_or_default(),
|
||||
"opted into RADV Vulkan Video decode (Mesa gates it behind RADV_PERFTEST on the Deck)"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// `--list-adapters`: print the Vulkan physical devices' marketing names (one per
|
||||
// line, discrete first) for the desktop shells' GPU picker, then exit.
|
||||
if arg_flag("--list-adapters") {
|
||||
return match pf_presenter::vk::list_adapters() {
|
||||
Ok(names) => {
|
||||
for n in names {
|
||||
println!("{n}");
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("list-adapters: {e:#}");
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `--list-audio`: the PipeWire endpoints the settings pickers offer, as
|
||||
// `sink|source<TAB>node.name<TAB>description` lines — a debug window into the
|
||||
// same enumeration the GTK shell probes.
|
||||
#[cfg(target_os = "linux")]
|
||||
if arg_flag("--list-audio") {
|
||||
return match pf_client_core::audio::devices() {
|
||||
Ok((sinks, sources)) => {
|
||||
for d in sinks {
|
||||
println!("sink\t{}\t{}", d.name, d.description);
|
||||
}
|
||||
for d in sources {
|
||||
println!("source\t{}\t{}", d.name, d.description);
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("list-audio: {e:#}");
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `--pair <PIN>`: enrol this machine against a host and exit. DEPRECATED — pairing is
|
||||
// a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell
|
||||
// (design/client-architecture-split.md §5). It still works, with a notice, for the one
|
||||
// release this needs; a renderer owning a trust ceremony is exactly the mixing of
|
||||
// concerns the split exists to undo.
|
||||
if let Some(pin) = arg_value("--pair") {
|
||||
eprintln!(
|
||||
"note: punktfunk-session --pair is deprecated \u{2014} use `punktfunk pair \
|
||||
<host[:port]>` instead (same store, same result)."
|
||||
);
|
||||
return headless_pair(&pin);
|
||||
}
|
||||
|
||||
// Before any Vulkan call: make RADV expose its video-decode queue + extensions so the
|
||||
// decoder's `auto` path prefers Vulkan Video over VAAPI (Steam Deck, and any gated RADV).
|
||||
// Windows drivers (NVIDIA/AMD Adrenalin) expose theirs unconditionally.
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_radv_video_decode();
|
||||
|
||||
// The Settings device picks → env, unless the user already forced one by hand:
|
||||
// the GPU (the shells' pickers store the adapter's marketing name) for the
|
||||
// presenter's device selection, and the audio endpoints (PipeWire node names)
|
||||
// for the playback/mic streams' `target.object`. Before any Vulkan call, like
|
||||
// the RADV knob (covers --connect and --browse).
|
||||
{
|
||||
let s = trust::Settings::load();
|
||||
for (var, value) in [
|
||||
("PUNKTFUNK_VK_ADAPTER", &s.adapter),
|
||||
("PUNKTFUNK_AUDIO_SINK", &s.speaker_device),
|
||||
("PUNKTFUNK_AUDIO_SOURCE", &s.mic_device),
|
||||
] {
|
||||
if std::env::var_os(var).is_none() && !value.is_empty() {
|
||||
std::env::set_var(var, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Steam launches its shortcuts with SDL_GAMECONTROLLER_IGNORE_DEVICES naming
|
||||
// every pad Steam Input has virtualized; capturing the Deck's real built-in
|
||||
// controller needs it cleared (same rationale as the GTK client's `app::run`).
|
||||
for var in [
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES",
|
||||
"SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT",
|
||||
] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
tracing::info!(var, value = %v, "clearing Steam's SDL device filter");
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
}
|
||||
|
||||
if arg_flag("--browse") {
|
||||
// Bare `--browse` opens the console home (hosts, pairing, settings);
|
||||
// `--browse host[:port]` opens straight into that host's library.
|
||||
let target = arg_value("--browse");
|
||||
#[cfg(feature = "ui")]
|
||||
return crate::console::run(target.as_deref());
|
||||
#[cfg(not(feature = "ui"))]
|
||||
{
|
||||
let _ = target;
|
||||
eprintln!(
|
||||
"--browse needs the console UI — this is the minimal build \
|
||||
(rebuild without --no-default-features)"
|
||||
);
|
||||
return EXIT_PRESENTER_FAILED;
|
||||
}
|
||||
}
|
||||
let Some(target) = arg_value("--connect") else {
|
||||
eprintln!(
|
||||
"usage: punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--profile REF] [--fullscreen]\n\
|
||||
\x20 punktfunk-session --browse [host[:port]] [--mgmt PORT] [--fullscreen] [--json-status]\n\
|
||||
\x20 punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]\n\
|
||||
\n\
|
||||
Streams from a paired punktfunk host in a Vulkan window. --browse opens the\n\
|
||||
gamepad console instead: bare --browse is the host list (discovery, PIN\n\
|
||||
pairing, settings, wake-on-LAN); with a target it opens that host's game\n\
|
||||
library. --profile picks a settings profile by id or name for this session\n\
|
||||
only (\"\" = the global defaults); without it the host's own profile applies.\n\
|
||||
--connect never dials a host it has no pinned fingerprint for —\n\
|
||||
enrol with --pair (no display needed), in the console, or from the desktop\n\
|
||||
client."
|
||||
);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
};
|
||||
let (addr, port) = parse_host_port(&target);
|
||||
|
||||
let identity = match trust::load_or_create_identity() {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
json_line("error", &format!("client identity: {e:#}"), None);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
};
|
||||
// `--resolved-spec <path>`: the spawner already did the resolving, so this process
|
||||
// performs ZERO store reads (design/client-architecture-split.md §5) — no Settings
|
||||
// load, no known-hosts lookup, no profile resolution. Without it (a hand-run
|
||||
// `--connect`, an old Decky script) the session resolves for itself through the SAME
|
||||
// helper, so the two modes cannot drift.
|
||||
let spec = arg_value("--resolved-spec").map(std::path::PathBuf::from);
|
||||
let (settings, profile_name, clipboard_override) = match &spec {
|
||||
Some(path) => match pf_client_core::orchestrate::ResolvedSpec::read(path) {
|
||||
Ok(s) => {
|
||||
tracing::info!(path = %path.display(), "running from a resolved spec");
|
||||
(s.settings, s.profile, Some(s.clipboard))
|
||||
}
|
||||
Err(e) => {
|
||||
json_line("error", &format!("resolved spec: {e}"), None);
|
||||
return EXIT_CONNECT_FAILED;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let (settings, profile) =
|
||||
trust::effective_settings(&addr, port, profile_arg().as_deref());
|
||||
(settings, profile.map(|p| p.name), None)
|
||||
}
|
||||
};
|
||||
if let Some(name) = &profile_name {
|
||||
tracing::info!(profile = %name, "streaming with a settings profile");
|
||||
}
|
||||
|
||||
// Trust follows the GTK client's `--connect` rules: a stored (or `--fp`) pin
|
||||
// connects silently; an unknown host is REFUSED — there is no dialog here, and a
|
||||
// silent TOFU would defeat the pinning model. Pair via the desktop client.
|
||||
let known = trust::KnownHosts::load();
|
||||
let known_host = known
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|h| h.addr == addr && h.port == port);
|
||||
let pin = arg_value("--fp")
|
||||
.as_deref()
|
||||
.and_then(trust::parse_hex32)
|
||||
.or_else(|| known_host.and_then(|h| trust::parse_hex32(&h.fp_hex)));
|
||||
let Some(pin) = pin else {
|
||||
json_line(
|
||||
"error",
|
||||
&format!(
|
||||
"no pinned fingerprint for {addr}:{port} — pair first \
|
||||
(punktfunk-session --pair <PIN> --connect {addr}:{port}) or pass --fp HEX"
|
||||
),
|
||||
Some(true),
|
||||
);
|
||||
return EXIT_TRUST_REJECTED;
|
||||
};
|
||||
|
||||
let host_label = known_host.map_or_else(|| addr.clone(), |h| h.name.clone());
|
||||
let launch = arg_value("--launch");
|
||||
let title = launch
|
||||
.clone()
|
||||
.map_or_else(|| host_label.clone(), |id| format!("{host_label} · {id}"));
|
||||
|
||||
let fullscreen = arg_flag("--fullscreen")
|
||||
|| std::env::var_os("SteamDeck").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some();
|
||||
|
||||
let opts = pf_presenter::SessionOpts {
|
||||
window_title: format!("Punktfunk · {title}"),
|
||||
fullscreen,
|
||||
window_pos: window_pos(),
|
||||
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||
// demoting an explicitly chosen richer tier.
|
||||
stats_verbosity: match settings.stats_verbosity() {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
mouse_mode: settings.mouse_mode(),
|
||||
invert_scroll: settings.invert_scroll,
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
})),
|
||||
// The Skia console UI (stats OSD, capture HUD) — compiled out of the
|
||||
// power-user build (`--no-default-features` drops the `ui` feature).
|
||||
#[cfg(feature = "ui")]
|
||||
overlay: Some(Box::new(pf_console_ui::SkiaOverlay::new())),
|
||||
#[cfg(not(feature = "ui"))]
|
||||
overlay: None,
|
||||
window_size: window_size(&settings),
|
||||
// A spawned session (spec mode) reports its window; a hand-run one persists it.
|
||||
match_window: match_window(&settings, spec.is_none()),
|
||||
render_scale: settings.render_scale,
|
||||
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
|
||||
};
|
||||
|
||||
let outcome =
|
||||
pf_presenter::run_session(opts, move |gamepad, native, force_software, vulkan| {
|
||||
session_params(
|
||||
&settings,
|
||||
profile_name,
|
||||
clipboard_override,
|
||||
addr,
|
||||
port,
|
||||
pin,
|
||||
identity,
|
||||
launch,
|
||||
gamepad,
|
||||
native,
|
||||
force_software,
|
||||
vulkan,
|
||||
)
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(pf_presenter::Outcome::Ended(None)) => 0,
|
||||
Ok(pf_presenter::Outcome::Ended(Some(reason))) => {
|
||||
// The host ending the session (game quit, host shutdown) is a normal end
|
||||
// for a one-shot stream binary — report the reason, exit clean.
|
||||
json_line("ended", &reason, None);
|
||||
0
|
||||
}
|
||||
Ok(pf_presenter::Outcome::ConnectFailed {
|
||||
msg,
|
||||
trust_rejected,
|
||||
}) => {
|
||||
json_line("error", &msg, Some(trust_rejected));
|
||||
if trust_rejected {
|
||||
EXIT_TRUST_REJECTED
|
||||
} else {
|
||||
EXIT_CONNECT_FAILED
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
json_line("error", &format!("presenter: {e:#}"), None);
|
||||
EXIT_PRESENTER_FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GTK4/SDL3 are Linux turf; this stub keeps `cargo build --workspace` green on macOS
|
||||
/// (the Mac client lives in clients/apple).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn main() -> std::process::ExitCode {
|
||||
std::process::ExitCode::from(session_main::run())
|
||||
}
|
||||
|
||||
/// This stub keeps `cargo build --workspace` green elsewhere (the Mac client lives in
|
||||
/// clients/apple).
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn main() {
|
||||
eprintln!("punktfunk-client is Linux-only — the macOS client lives in clients/apple");
|
||||
eprintln!(
|
||||
"punktfunk-session runs on Linux and Windows — the macOS client lives in clients/apple"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a
|
||||
//! profile or a game), design/client-deep-links.md §5.
|
||||
//!
|
||||
//! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the
|
||||
//! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser
|
||||
//! prompt use. That is deliberate — it keeps working if scheme registration is lost or the
|
||||
//! host store changes, because the URL itself carries the stable id, the address and the pin.
|
||||
//!
|
||||
//! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the
|
||||
//! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for
|
||||
//! exactly this, with its own confirmation) is the intended upgrade there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and
|
||||
/// nowhere else — the standard check, and the one the portal docs use.
|
||||
pub fn sandboxed() -> bool {
|
||||
std::path::Path::new("/.flatpak-info").exists()
|
||||
}
|
||||
|
||||
/// Write `~/.local/share/applications/punktfunk-<slug>.desktop` for this URL and return the
|
||||
/// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still
|
||||
/// works from a file manager, it just won't show up in search straight away.
|
||||
pub fn write_desktop_entry(label: &str, url: &str) -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?;
|
||||
let dir = PathBuf::from(home).join(".local/share/applications");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label)));
|
||||
// Desktop-entry values are line-oriented: a newline in a host name would end the Name
|
||||
// key and turn the rest into an unparsable line (or, worse, another key).
|
||||
let name = one_line(label);
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\n\
|
||||
Type=Application\n\
|
||||
Name={name}\n\
|
||||
Comment=Stream from this Punktfunk host\n\
|
||||
Exec=punktfunk-client \"{url}\"\n\
|
||||
Icon=io.unom.Punktfunk\n\
|
||||
Terminal=false\n\
|
||||
Categories=Game;Network;\n\
|
||||
StartupNotify=true\n"
|
||||
);
|
||||
std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
// Some desktops only offer a `.desktop` as a launchable icon when it is executable.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let _ = std::process::Command::new("update-desktop-database")
|
||||
.arg(&dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`,
|
||||
/// capped so a long host+profile pair can't produce a name the filesystem rejects.
|
||||
fn file_slug(label: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in label.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-');
|
||||
let capped: String = trimmed.chars().take(48).collect();
|
||||
if capped.is_empty() {
|
||||
"host".to_string()
|
||||
} else {
|
||||
capped
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic.
|
||||
fn one_line(s: &str) -> String {
|
||||
s.replace(['\n', '\r'], " ").trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Names become safe filenames, and a name that survives to `Name=` can't break the file.
|
||||
#[test]
|
||||
fn labels_are_sanitised_both_ways() {
|
||||
assert_eq!(file_slug("Living Room PC"), "living-room-pc");
|
||||
assert_eq!(file_slug("Büro · Mac"), "b-ro-mac");
|
||||
assert_eq!(file_slug("Desk · Work"), "desk-work");
|
||||
assert_eq!(file_slug("////"), "host");
|
||||
assert_eq!(file_slug(""), "host");
|
||||
assert!(file_slug(&"x".repeat(200)).len() <= 48);
|
||||
// The classic injection: a newline would end the Name key and start a new one.
|
||||
assert_eq!(
|
||||
one_line("Desk\nExec=rm -rf ~"),
|
||||
"Desk Exec=rm -rf ~".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
//! The shell↔session handoff: every stream runs in the spawned `punktfunk-session`
|
||||
//! Vulkan binary (the legacy in-process presenter is gone — phase 5 of
|
||||
//! punktfunk-planning `linux-client-rearchitecture.md`). What is left here is the
|
||||
//! TRANSLATION: a [`ConnectRequest`] becomes a `ConnectPlan`, and the session's typed
|
||||
//! lifecycle events become the [`AppMsg`]s the relm4 app consumes — spinner until
|
||||
//! `{"ready":true}`, banner from the `{"error"|"ended": …}` line, exit code 3 +
|
||||
//! `trust_rejected` routed to the re-pair PIN ceremony.
|
||||
//!
|
||||
//! Spawning, the argv, the stdout contract and the child handle live in
|
||||
//! `pf_client_core::orchestrate` (design/client-architecture-split.md §3) — the WinUI shell
|
||||
//! and the coming CLI spawn through the same code, so "what flags does a stream get" and
|
||||
//! "what does ready mean" have exactly one answer.
|
||||
|
||||
use crate::app::AppMsg;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use pf_client_core::orchestrate::{self, ConnectPlan, HostTarget, SessionEvent};
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
/// Spawn tunables beyond a plain connect.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SpawnOpts {
|
||||
/// Handshake budget override (`--connect-timeout`) — the request-access flow passes
|
||||
/// ~185 s because the host PARKS the connection until the operator approves.
|
||||
pub connect_timeout_secs: Option<u64>,
|
||||
/// Persist the host as *paired* once the child reports ready (request-access: the
|
||||
/// operator's approval IS the pairing). Plain TOFU persists unpaired.
|
||||
pub persist_paired: bool,
|
||||
/// A cancel handle to arm (request-access's waiting dialog): killing the child is
|
||||
/// the only abort a parked connect has.
|
||||
pub cancel: Option<CancelHandle>,
|
||||
}
|
||||
|
||||
pub use orchestrate::{session_binary, CancelHandle};
|
||||
|
||||
/// Spawn the session binary for a connect with `fp_hex` pinned and translate its
|
||||
/// lifecycle into [`AppMsg`]s. `tofu` = the fingerprint came from the host's advert
|
||||
/// rather than the store — the app persists it once the child reports ready (the child
|
||||
/// connects pinned to it, so ready proves the host really holds that identity).
|
||||
///
|
||||
/// The caller has already taken `busy`; [`AppMsg::SessionExited`] releases it. `Err` =
|
||||
/// the spawn itself failed (binary missing?) — surfaced as a connect error.
|
||||
pub fn spawn_session(
|
||||
sender: relm4::Sender<AppMsg>,
|
||||
req: ConnectRequest,
|
||||
fp_hex: String,
|
||||
tofu: bool,
|
||||
fullscreen_on_stream: bool,
|
||||
opts: SpawnOpts,
|
||||
) -> Result<(), String> {
|
||||
// The plan this connect resolves to. A plain card click carries no `--profile`: it honors
|
||||
// the host's own binding, which the session resolves through the same helper this shell
|
||||
// would have used (design/client-settings-profiles.md §4.6) — passing it here would be a
|
||||
// second source of truth for one decision. Only a "Connect with ▸" pick (or a URL's
|
||||
// `profile=`) sets one, and it applies to this session alone.
|
||||
//
|
||||
// Two fields are deliberately not the shell's state yet, because nothing in the spawn path
|
||||
// reads them: `settings` carries only what the argv needs (the fullscreen flag), and `wake`
|
||||
// is false because this shell still runs its own dial-first wake fallback in `app.rs`. Both
|
||||
// become real when the GTK connect path moves onto `ConnectOrchestrator` (arch-split C0).
|
||||
let plan = ConnectPlan {
|
||||
host: HostTarget {
|
||||
name: req.name.clone(),
|
||||
addr: req.addr.clone(),
|
||||
port: req.port,
|
||||
fp_hex: Some(fp_hex.clone()),
|
||||
mac: req.mac.clone(),
|
||||
id: None,
|
||||
},
|
||||
launch: req.launch.as_ref().map(|(id, _)| id.clone()),
|
||||
profile: None,
|
||||
// A one-off pick rides the flag; without one the session resolves the host's own
|
||||
// binding through the same helper this shell would have used.
|
||||
profile_override: req.profile.clone(),
|
||||
settings: Settings {
|
||||
fullscreen_on_stream,
|
||||
..Default::default()
|
||||
},
|
||||
wake: false,
|
||||
connect_timeout_secs: opts.connect_timeout_secs,
|
||||
tofu,
|
||||
// The per-host clipboard decision, resolved here so the child doesn't look it up
|
||||
// again — matched by address, the way every other per-host lookup matches.
|
||||
clipboard: pf_client_core::trust::KnownHosts::load()
|
||||
.hosts
|
||||
.iter()
|
||||
.any(|h| h.addr == req.addr && h.port == req.port && h.clipboard_sync),
|
||||
};
|
||||
|
||||
let persist_paired = opts.persist_paired;
|
||||
let (mut error, mut ended) = (None::<(String, bool)>, None::<String>);
|
||||
orchestrate::spawn_session(&plan, opts.cancel, move |ev| match ev {
|
||||
SessionEvent::Ready => {
|
||||
let _ = sender.send(AppMsg::SessionReady {
|
||||
req: req.clone(),
|
||||
fp_hex: fp_hex.clone(),
|
||||
tofu,
|
||||
persist_paired,
|
||||
});
|
||||
}
|
||||
SessionEvent::Error {
|
||||
msg,
|
||||
trust_rejected,
|
||||
} => error = Some((msg, trust_rejected)),
|
||||
SessionEvent::Ended(msg) => ended = Some(msg),
|
||||
// The brain persists the window size; the shell has nothing to do with it.
|
||||
SessionEvent::Window { .. } => {}
|
||||
SessionEvent::Exited(code) => {
|
||||
let _ = sender.send(AppMsg::SessionExited {
|
||||
req: req.clone(),
|
||||
code,
|
||||
error: error.take(),
|
||||
ended: ended.take(),
|
||||
tofu,
|
||||
});
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
//! The game-library page (the Apple `LibraryView` ported): a poster grid of the host's
|
||||
//! unified library fetched over the management API (`library.rs`), pushed onto the nav
|
||||
//! stack from a saved card's "Browse library…" action. Poster art loads asynchronously
|
||||
//! (worker threads → texture on the main loop) with a monogram placeholder, and tapping
|
||||
//! a title starts a session that asks the host to launch it (the library id rides the
|
||||
//! Hello via `ConnectRequest::launch`).
|
||||
|
||||
use crate::app::{AppModel, AppMsg};
|
||||
use crate::library::{self, GameEntry};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::{gdk, glib};
|
||||
use relm4::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Everything the page re-renders from. Kept alive by the widget closures (reload/retry/
|
||||
/// card activation); dropped when the page is popped, which also winds down any in-flight
|
||||
/// art consumer (its weak upgrade fails).
|
||||
struct State {
|
||||
sender: ComponentSender<AppModel>,
|
||||
identity: (String, String),
|
||||
/// The advertised mgmt port when the host was live at open time (else the default).
|
||||
mgmt_port: u16,
|
||||
/// The host this library belongs to — cards clone it and add `launch`.
|
||||
req: ConnectRequest,
|
||||
stack: gtk::Stack,
|
||||
flow: gtk::FlowBox,
|
||||
error_page: adw::StatusPage,
|
||||
/// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching.
|
||||
art: RefCell<HashMap<String, gdk::Texture>>,
|
||||
/// The Picture each entry currently renders into (rebuilt per render), so async art
|
||||
/// results land on the right card.
|
||||
pics: RefCell<HashMap<String, gtk::Picture>>,
|
||||
/// Screenshot mode: render injected entries only, never touch the network.
|
||||
mock: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
|
||||
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
|
||||
pub fn open(
|
||||
app: &AppModel,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
mgmt_port: Option<u16>,
|
||||
) {
|
||||
let state = build(&app.nav, app.identity.clone(), sender, req, mgmt_port);
|
||||
load(&state);
|
||||
}
|
||||
|
||||
/// Screenshot-scene entry: render injected entries (plus pre-seeded textures, keyed by
|
||||
/// entry id) with no host and no network — the CI `library` scene.
|
||||
pub fn open_mock(
|
||||
nav: &adw::NavigationView,
|
||||
identity: (String, String),
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
games: Vec<GameEntry>,
|
||||
art: Vec<(String, gdk::Texture)>,
|
||||
) {
|
||||
let state = build(nav, identity, sender, req, None);
|
||||
state.mock.set(true);
|
||||
state.art.borrow_mut().extend(art);
|
||||
if games.is_empty() {
|
||||
state.stack.set_visible_child_name("empty");
|
||||
} else {
|
||||
render(&state, &games);
|
||||
state.stack.set_visible_child_name("grid");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the page (loading / error / empty / grid states in a stack) and push it.
|
||||
fn build(
|
||||
nav: &adw::NavigationView,
|
||||
identity: (String, String),
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
mgmt_port: Option<u16>,
|
||||
) -> Rc<State> {
|
||||
let flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.activate_on_single_click(true)
|
||||
.homogeneous(true)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(6)
|
||||
.column_spacing(12)
|
||||
.row_spacing(18)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
|
||||
// `activate` — bridge it so each poster's connect handler (below) runs on click.
|
||||
flow.connect_child_activated(|_, child| {
|
||||
child.activate();
|
||||
});
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
content.set_margin_top(24);
|
||||
content.set_margin_bottom(24);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&flow);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(1100)
|
||||
.child(&content)
|
||||
.build();
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let loading = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||
loading.set_valign(gtk::Align::Center);
|
||||
let spinner = gtk::Spinner::new();
|
||||
spinner.set_size_request(32, 32);
|
||||
spinner.start();
|
||||
spinner.set_halign(gtk::Align::Center);
|
||||
loading.append(&spinner);
|
||||
let loading_label = gtk::Label::new(Some("Loading library…"));
|
||||
loading_label.add_css_class("dim-label");
|
||||
loading.append(&loading_label);
|
||||
|
||||
let error_page = adw::StatusPage::builder()
|
||||
.icon_name("dialog-error-symbolic")
|
||||
.title("Couldn't load the library")
|
||||
.build();
|
||||
let retry = gtk::Button::with_label("Retry");
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_halign(gtk::Align::Center);
|
||||
error_page.set_child(Some(&retry));
|
||||
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("applications-games-symbolic")
|
||||
.title("No games found")
|
||||
.description(
|
||||
"No games found on this host. Install Steam titles or add custom \
|
||||
entries in the host's web console.",
|
||||
)
|
||||
.build();
|
||||
|
||||
let stack = gtk::Stack::new();
|
||||
stack.add_named(&loading, Some("loading"));
|
||||
stack.add_named(&error_page, Some("error"));
|
||||
stack.add_named(&empty, Some("empty"));
|
||||
stack.add_named(&scrolled, Some("grid"));
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let reload = gtk::Button::from_icon_name("view-refresh-symbolic");
|
||||
reload.set_tooltip_text(Some("Reload"));
|
||||
header.pack_end(&reload);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&stack));
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title(format!("{} — Library", req.name))
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
|
||||
let state = Rc::new(State {
|
||||
sender: sender.clone(),
|
||||
identity,
|
||||
mgmt_port: mgmt_port.unwrap_or(library::DEFAULT_MGMT_PORT),
|
||||
req,
|
||||
stack,
|
||||
flow,
|
||||
error_page,
|
||||
art: RefCell::new(HashMap::new()),
|
||||
pics: RefCell::new(HashMap::new()),
|
||||
mock: Cell::new(false),
|
||||
});
|
||||
{
|
||||
let state = state.clone();
|
||||
reload.connect_clicked(move |_| load(&state));
|
||||
}
|
||||
{
|
||||
let state = state.clone();
|
||||
retry.connect_clicked(move |_| load(&state));
|
||||
}
|
||||
nav.push(&page);
|
||||
state
|
||||
}
|
||||
|
||||
/// Fetch the library off the main thread and route the result into the grid or the
|
||||
/// error/empty states.
|
||||
fn load(state: &Rc<State>) {
|
||||
if state.mock.get() {
|
||||
return; // screenshot scene renders injected entries only
|
||||
}
|
||||
state.stack.set_visible_child_name("loading");
|
||||
let port = state.mgmt_port;
|
||||
let addr = state.req.addr.clone();
|
||||
let identity = state.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-library".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send_blocking(library::fetch_games(&addr, port, &identity, pin));
|
||||
})
|
||||
.expect("spawn library thread");
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
let Ok(result) = rx.recv().await else { return };
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
match result {
|
||||
Ok(games) if games.is_empty() => state.stack.set_visible_child_name("empty"),
|
||||
Ok(games) => {
|
||||
render(&state, &games);
|
||||
state.stack.set_visible_child_name("grid");
|
||||
load_art(&state, &games);
|
||||
}
|
||||
Err(e) => {
|
||||
state.error_page.set_description(Some(&e.to_string()));
|
||||
state.stack.set_visible_child_name("error");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// (Re)build the poster grid from one library snapshot. Cached textures apply
|
||||
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
|
||||
fn render(state: &Rc<State>, games: &[GameEntry]) {
|
||||
state.flow.remove_all();
|
||||
state.pics.borrow_mut().clear();
|
||||
for game in games {
|
||||
state.flow.append(&game_card(state, game));
|
||||
}
|
||||
}
|
||||
|
||||
/// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a
|
||||
/// monogram placeholder underneath the async art. Activation starts a session launching
|
||||
/// this title (silent on a pinned host — the normal trust gate applies).
|
||||
fn game_card(state: &Rc<State>, game: &GameEntry) -> gtk::FlowBoxChild {
|
||||
let monogram = gtk::Label::new(Some(&initials(&game.title)));
|
||||
monogram.add_css_class("pf-poster-monogram");
|
||||
monogram.set_halign(gtk::Align::Center);
|
||||
monogram.set_valign(gtk::Align::Center);
|
||||
let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
placeholder.append(&monogram);
|
||||
monogram.set_vexpand(true);
|
||||
|
||||
let pic = gtk::Picture::new();
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
if let Some(tex) = state.art.borrow().get(&game.id) {
|
||||
pic.set_paintable(Some(tex));
|
||||
}
|
||||
state.pics.borrow_mut().insert(game.id.clone(), pic.clone());
|
||||
|
||||
let badge = gtk::Label::new(Some(store_label(&game.store)));
|
||||
badge.add_css_class("pf-pill");
|
||||
badge.add_css_class("pf-store-badge");
|
||||
badge.set_halign(gtk::Align::Start);
|
||||
badge.set_valign(gtk::Align::Start);
|
||||
badge.set_margin_start(6);
|
||||
badge.set_margin_top(6);
|
||||
|
||||
let poster = gtk::Overlay::new();
|
||||
poster.set_child(Some(&placeholder));
|
||||
poster.add_overlay(&pic);
|
||||
poster.add_overlay(&badge);
|
||||
poster.add_css_class("pf-poster");
|
||||
poster.set_overflow(gtk::Overflow::Hidden);
|
||||
poster.set_size_request(150, 225);
|
||||
poster.set_halign(gtk::Align::Center);
|
||||
|
||||
let title = gtk::Label::new(Some(&game.title));
|
||||
title.add_css_class("caption");
|
||||
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||
title.set_max_width_chars(16);
|
||||
title.set_tooltip_text(Some(&game.title));
|
||||
|
||||
let card = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
card.append(&poster);
|
||||
card.append(&title);
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
child.set_child(Some(&card));
|
||||
let sender = state.sender.clone();
|
||||
let mut req = state.req.clone();
|
||||
req.launch = Some((game.id.clone(), game.title.clone()));
|
||||
child.connect_activate(move |_| sender.input(AppMsg::Connect(req.clone())));
|
||||
child
|
||||
}
|
||||
|
||||
/// Fetch poster art for every uncached entry on a small worker pool, walking each
|
||||
/// entry's candidates in the Apple fallback order (portrait → header → hero) and
|
||||
/// texturing the first that loads on the main loop.
|
||||
fn load_art(state: &Rc<State>, games: &[GameEntry]) {
|
||||
let base = library::base_url(&state.req.addr, state.mgmt_port);
|
||||
let jobs: VecDeque<(String, Vec<String>)> = {
|
||||
let cache = state.art.borrow();
|
||||
games
|
||||
.iter()
|
||||
.filter(|g| !cache.contains_key(&g.id))
|
||||
.map(|g| (g.id.clone(), g.art.poster_candidates(&base)))
|
||||
.filter(|(_, candidates)| !candidates.is_empty())
|
||||
.collect()
|
||||
};
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let identity = state.identity.clone();
|
||||
let pin = state.req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||||
let rx = library::spawn_art_fetch(base, identity, pin, jobs);
|
||||
let weak = Rc::downgrade(state);
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok((id, bytes)) = rx.recv().await {
|
||||
let Some(state) = weak.upgrade() else { break };
|
||||
// Texture decode happens here on the main loop — posters are small (tens of
|
||||
// KB), and `from_bytes` handles jpeg/png alike.
|
||||
match gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes)) {
|
||||
Ok(tex) => {
|
||||
if let Some(pic) = state.pics.borrow().get(&id) {
|
||||
pic.set_paintable(Some(&tex));
|
||||
}
|
||||
state.art.borrow_mut().insert(id, tex);
|
||||
}
|
||||
Err(e) => tracing::debug!(%id, error = %e, "undecodable poster"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The store badge text — `store` comes from the entry (today `steam`/`custom`; future
|
||||
/// stores per the host's provider list), with the id prefix as a fallback spelling.
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn store_label(store: &str) -> &'static str {
|
||||
match store {
|
||||
"steam" => "Steam",
|
||||
"custom" => "Custom",
|
||||
"heroic" => "Heroic",
|
||||
"lutris" => "Lutris",
|
||||
"epic" => "Epic",
|
||||
"gog" => "GOG",
|
||||
"xbox" => "Xbox",
|
||||
_ => "Game",
|
||||
}
|
||||
}
|
||||
|
||||
/// Monogram for the placeholder tile: the first letters of the first two words.
|
||||
/// Shared with the gamepad launcher's posters.
|
||||
pub fn initials(title: &str) -> String {
|
||||
title
|
||||
.split_whitespace()
|
||||
.take(2)
|
||||
.filter_map(|w| w.chars().next())
|
||||
.flat_map(char::to_uppercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn initials_take_two_words() {
|
||||
assert_eq!(initials("Dota 2"), "D2");
|
||||
assert_eq!(initials("half-life"), "H");
|
||||
assert_eq!(initials("The Witness III"), "TW");
|
||||
assert_eq!(initials(""), "");
|
||||
}
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
||||
//! delegated (request-access) approval. The trust GATE itself (rules 1–3) lives in
|
||||
//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens,
|
||||
//! each resolving into typed [`AppMsg`]s.
|
||||
|
||||
use crate::app::{AppModel, AppMsg};
|
||||
use crate::spawn::{CancelHandle, SpawnOpts};
|
||||
use crate::trust;
|
||||
use crate::ui_hosts::ConnectRequest;
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
|
||||
use relm4::prelude::*;
|
||||
|
||||
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
|
||||
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
|
||||
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
|
||||
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
|
||||
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
|
||||
/// lets the user cancel.
|
||||
///
|
||||
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
|
||||
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
|
||||
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
|
||||
pub fn wake_and_connect(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waking Host"),
|
||||
Some(&format!(
|
||||
"Sent a wake signal to “{}”. Waiting for it to come online…",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
|
||||
}
|
||||
waiting.present(Some(window));
|
||||
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::Duration;
|
||||
let events = crate::discovery::browse();
|
||||
let mut wait = WakeWait::new();
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
return;
|
||||
}
|
||||
// Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
|
||||
// and carries the address it came back on.
|
||||
let mut seen: Option<(String, u16)> = None;
|
||||
while let Ok(ev) = events.try_recv() {
|
||||
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
|
||||
continue;
|
||||
};
|
||||
let matched = match &req.fp_hex {
|
||||
Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp,
|
||||
None => h.addr == req.addr && h.port == req.port,
|
||||
};
|
||||
if matched {
|
||||
seen = Some((h.addr, h.port));
|
||||
}
|
||||
}
|
||||
let tick = wait.tick(seen.is_some());
|
||||
if tick.send_packet {
|
||||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||||
}
|
||||
match tick.outcome {
|
||||
Some(WakeOutcome::Online) => {
|
||||
waiting.close();
|
||||
let mut req = req.clone();
|
||||
// Re-key on a new DHCP lease so this + future connects dial the
|
||||
// live address.
|
||||
if let Some((addr, port)) =
|
||||
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
|
||||
{
|
||||
if let Some(fp) = &req.fp_hex {
|
||||
trust::rekey_addr(fp, &addr, port);
|
||||
}
|
||||
req.addr = addr;
|
||||
req.port = port;
|
||||
}
|
||||
sender.input(AppMsg::Connect(req));
|
||||
return;
|
||||
}
|
||||
Some(WakeOutcome::TimedOut) => {
|
||||
waiting.close();
|
||||
sender.input(AppMsg::Toast(format!(
|
||||
"Couldn't reach “{}” — is it powered and on the network?",
|
||||
req.name
|
||||
)));
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
glib::timeout_future(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines,
|
||||
/// far easier to compare against the host's log than one 64-char run.
|
||||
fn grouped_fingerprint(fp: &str) -> String {
|
||||
let groups: Vec<&str> = fp
|
||||
.as_bytes()
|
||||
.chunks(4)
|
||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||
.collect();
|
||||
groups
|
||||
.chunks(8)
|
||||
.map(|line| line.join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// First contact with a discovered host that opted into TOFU: show the advertised
|
||||
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
|
||||
/// Trusting starts a session pinned to the advertised fp; it persists once the child
|
||||
/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`).
|
||||
pub fn tofu_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let fp = req.fp_hex.clone().unwrap_or_default();
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("New Host"),
|
||||
Some(&format!(
|
||||
"{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \
|
||||
trusting accepts it as-is.",
|
||||
req.name, req.addr, req.port
|
||||
)),
|
||||
);
|
||||
let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp)));
|
||||
fp_label.add_css_class("monospace");
|
||||
fp_label.set_selectable(true);
|
||||
fp_label.set_justify(gtk::Justification::Center);
|
||||
fp_label.set_halign(gtk::Align::Center);
|
||||
dialog.set_extra_child(Some(&fp_label));
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pair", "Pair with PIN…"),
|
||||
("trust", "Trust & Connect"),
|
||||
]);
|
||||
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("trust"));
|
||||
dialog.set_close_response("cancel");
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"trust" => sender.input(AppMsg::StartSession {
|
||||
req: req.clone(),
|
||||
fp_hex: fp.clone(),
|
||||
tofu: true,
|
||||
opts: SpawnOpts::default(),
|
||||
}),
|
||||
"pair" => sender.input(AppMsg::Pair(req.clone())),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
|
||||
/// of it pins the host's certificate (and registers ours) with no offline-guessable
|
||||
/// transcript. Success persists the host as paired and connects.
|
||||
pub fn pin_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
identity: (String, String),
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let entry = gtk::Entry::builder()
|
||||
.input_purpose(gtk::InputPurpose::Digits)
|
||||
.placeholder_text("4-digit PIN shown by the host")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
// The label the HOST stores this client under — prefilled with the hostname.
|
||||
let name_entry = gtk::Entry::builder()
|
||||
.text(glib::host_name().as_str())
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let name_caption = gtk::Label::new(Some("This device"));
|
||||
name_caption.add_css_class("caption");
|
||||
name_caption.add_css_class("dim-label");
|
||||
name_caption.set_halign(gtk::Align::Start);
|
||||
let fields = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
fields.append(&name_caption);
|
||||
fields.append(&name_entry);
|
||||
let pin_caption = gtk::Label::new(Some("PIN"));
|
||||
pin_caption.add_css_class("caption");
|
||||
pin_caption.add_css_class("dim-label");
|
||||
pin_caption.set_halign(gtk::Align::Start);
|
||||
fields.append(&pin_caption);
|
||||
fields.append(&entry);
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pair with PIN"),
|
||||
Some(&format!(
|
||||
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.set_extra_child(Some(&fields));
|
||||
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
|
||||
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("pair"));
|
||||
dialog.set_close_response("cancel");
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(Some("pair"), move |_, _| {
|
||||
let pin = entry.text().to_string();
|
||||
let req = req.clone();
|
||||
let identity = identity.clone();
|
||||
let sender = sender.clone();
|
||||
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
|
||||
let device = name_entry.text().trim().to_string();
|
||||
let name = if device.is_empty() {
|
||||
glib::host_name().to_string()
|
||||
} else {
|
||||
device
|
||||
};
|
||||
let (host, port) = (req.addr.clone(), req.port);
|
||||
std::thread::spawn(move || {
|
||||
// Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host
|
||||
// rejection) — never blame the PIN for a dead network path.
|
||||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
||||
.map_err(|e| trust::pair_error_message(&e));
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(fp)) => {
|
||||
let fp_hex = trust::hex(&fp);
|
||||
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
|
||||
sender.input(AppMsg::Toast("Paired — connecting…".into()));
|
||||
sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts::default(),
|
||||
});
|
||||
}
|
||||
Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)),
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// A fresh host that requires pairing: "Request access" (connect and wait for the
|
||||
/// operator to click Approve in the host's console — delegated approval) or the PIN
|
||||
/// ceremony.
|
||||
pub fn approval_dialog(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some("Pairing Required"),
|
||||
Some(&format!(
|
||||
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
|
||||
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("pin", "Use a PIN instead…"),
|
||||
("request", "Request Access"),
|
||||
]);
|
||||
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("request"));
|
||||
dialog.set_close_response("cancel");
|
||||
let parent = window.clone();
|
||||
let window = window.clone();
|
||||
let sender = sender.clone();
|
||||
dialog.connect_response(None, move |_, response| match response {
|
||||
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
|
||||
"pin" => sender.input(AppMsg::Pair(req.clone())),
|
||||
_ => {}
|
||||
});
|
||||
dialog.present(Some(&parent));
|
||||
}
|
||||
|
||||
/// The no-PIN "request access" flow: the session child opens an identified connect the
|
||||
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
|
||||
/// dialog covers the wait. On approval the same connection is admitted and the host is
|
||||
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
|
||||
///
|
||||
/// The pinned fingerprint is the advertised one for a discovered host (defence against
|
||||
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
|
||||
/// the session binary refuses pinless connects, so this path requires the advert; a
|
||||
/// manual entry's Request Access rides the same flow only when a fingerprint exists.
|
||||
fn request_access(
|
||||
window: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<AppModel>,
|
||||
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
|
||||
req: ConnectRequest,
|
||||
) {
|
||||
let Some(fp_hex) = req.fp_hex.clone() else {
|
||||
// No fingerprint to pin (manual entry): the strict child can't do a
|
||||
// trust-on-approval connect — route to the PIN ceremony instead.
|
||||
sender.input(AppMsg::Toast(
|
||||
"No advertised identity for this host — pair with a PIN instead.".into(),
|
||||
));
|
||||
sender.input(AppMsg::Pair(req));
|
||||
return;
|
||||
};
|
||||
let cancel = CancelHandle::default();
|
||||
let waiting = adw::AlertDialog::new(
|
||||
Some("Waiting for Approval"),
|
||||
Some(&format!(
|
||||
"Approve “{}” in {}’s console or web UI.\n\nThis device is waiting to be let in — it \
|
||||
connects automatically once you approve it.",
|
||||
glib::host_name(),
|
||||
req.name
|
||||
)),
|
||||
);
|
||||
waiting.add_responses(&[("cancel", "Cancel")]);
|
||||
waiting.set_close_response("cancel");
|
||||
{
|
||||
let sender = sender.clone();
|
||||
let cancel = cancel.clone();
|
||||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||||
cancel.kill();
|
||||
sender.input(AppMsg::CancelPending);
|
||||
});
|
||||
}
|
||||
waiting.present(Some(window));
|
||||
*waiting_slot.borrow_mut() = Some(waiting);
|
||||
|
||||
sender.input(AppMsg::StartSession {
|
||||
req,
|
||||
fp_hex,
|
||||
tofu: false,
|
||||
opts: SpawnOpts {
|
||||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
|
||||
// operator approval still lands on this connection.
|
||||
connect_timeout_secs: Some(185),
|
||||
persist_paired: true,
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Runs the REAL `punktfunk-session` binary and asserts it speaks the stdout contract.
|
||||
//!
|
||||
//! 0.22.0 shipped a stub as `punktfunk-session` — a stray copy of the GTK shell replaced
|
||||
//! this crate's `main.rs`, and every build gate stayed green because the wrong program
|
||||
//! compiled perfectly. Nothing in CI ever *ran* the binary; the failure only existed at
|
||||
//! runtime, as a connect that silently bounced back to the host list. This test is the
|
||||
//! gate that would have caught it: whatever the binary does, it must SAY so on stdout.
|
||||
#![cfg(any(target_os = "linux", windows))]
|
||||
|
||||
use std::io::Read as _;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A failing connect must still produce a contract line. Port 1 on loopback refuses
|
||||
/// instantly and the all-zero pin keeps trust logic out of the way; whatever fails first
|
||||
/// on this machine — presenter init on a headless runner, the dial on a box with a
|
||||
/// display — the contract requires one `{"error"…}` JSON line on stdout saying so. (On a
|
||||
/// desktop this may flash a window for under a second; the connect refuses immediately.)
|
||||
#[test]
|
||||
fn a_failing_connect_still_speaks_the_contract() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_punktfunk-session"))
|
||||
.args([
|
||||
"--connect",
|
||||
"127.0.0.1:1",
|
||||
"--fp",
|
||||
&"0".repeat(64),
|
||||
"--connect-timeout",
|
||||
"5",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn punktfunk-session");
|
||||
|
||||
let mut stdout = child.stdout.take().expect("piped stdout");
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
let _ = stdout.read_to_string(&mut buf);
|
||||
buf
|
||||
});
|
||||
|
||||
// Generous bound: presenter init on a cold CI runner can be slow, but a healthy
|
||||
// binary answers in seconds. Only a hang (or a stub that inherited our stdout and
|
||||
// blocked) gets anywhere near it.
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
match child.try_wait().expect("wait on punktfunk-session") {
|
||||
Some(_) => break,
|
||||
None if Instant::now() > deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("punktfunk-session neither exited nor spoke within 120 s");
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(200)),
|
||||
}
|
||||
}
|
||||
|
||||
let out = reader.join().expect("stdout reader");
|
||||
let spoke = out.lines().map(str::trim).any(|l| {
|
||||
l.starts_with('{')
|
||||
&& (l.contains("\"error\"") || l.contains("\"ready\"") || l.contains("\"ended\""))
|
||||
});
|
||||
assert!(
|
||||
spoke,
|
||||
"no stdout-contract line — the wrong program may be wearing this binary's name. \
|
||||
stdout was:\n{out}"
|
||||
);
|
||||
}
|
||||
@@ -287,7 +287,7 @@ fn connect_spawn(
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||
SpawnEvent::Exited { error, ended } => {
|
||||
SpawnEvent::Exited { error, ended, code } => {
|
||||
match error {
|
||||
Some((msg, true)) => {
|
||||
// Pinned-fingerprint mismatch / pairing required → re-pair via
|
||||
@@ -311,8 +311,14 @@ fn connect_spawn(
|
||||
}
|
||||
// `ended` = the host ended the session (banner); a clean exit
|
||||
// (user closed the stream window / Disconnect) returns silently.
|
||||
// A child that said nothing AND failed gets the exit code, so the
|
||||
// return to the host list is never unexplained.
|
||||
None => {
|
||||
st.call(ended.unwrap_or_default());
|
||||
st.call(
|
||||
ended
|
||||
.or_else(|| crate::spawn::silent_exit_banner(code))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
@@ -367,11 +373,18 @@ pub(crate) fn open_console(
|
||||
ss.call(Screen::Stream);
|
||||
}
|
||||
SpawnEvent::Stats(line) => *shared.stats_line.lock().unwrap() = line,
|
||||
SpawnEvent::Exited { error, ended } => {
|
||||
SpawnEvent::Exited { error, ended, code } => {
|
||||
crate::shell_window::restore();
|
||||
// Quit from the library (B / closing the window) returns silently;
|
||||
// a failed start surfaces its error line.
|
||||
st.call(error.map(|(msg, _)| msg).or(ended).unwrap_or_default());
|
||||
// a failed start surfaces its error line, or the exit code when it
|
||||
// died without producing one.
|
||||
st.call(
|
||||
error
|
||||
.map(|(msg, _)| msg)
|
||||
.or(ended)
|
||||
.or_else(|| crate::spawn::silent_exit_banner(code))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
ss.call(Screen::Hosts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,16 @@ pub(crate) enum SpawnEvent {
|
||||
/// One `stats:` line, already human-formatted by the session (per 1 s window).
|
||||
Stats(String),
|
||||
/// The child exited (stdout EOF + reap; a kill lands here too). `error`/`ended`
|
||||
/// carry the contract lines seen on the way out, when any (the exit code is logged
|
||||
/// by the reader; routing keys off the lines, which say strictly more).
|
||||
/// carry the contract lines seen on the way out, when any — routing keys off those,
|
||||
/// which say strictly more than a number. `code` is the process exit status (-1 = no
|
||||
/// code, i.e. killed) and exists for the case where there were NO lines at all: a
|
||||
/// child that dies before it can speak the contract would otherwise be indistinguishable
|
||||
/// from a clean user-initiated quit, and the shell would bounce to the host list with a
|
||||
/// blank banner. That is exactly how the 0.22.0 session-binary regression presented.
|
||||
Exited {
|
||||
error: Option<(String, bool)>,
|
||||
ended: Option<String>,
|
||||
code: i32,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -75,6 +80,19 @@ fn parse_line(line: &str) -> Option<ChildLine> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The banner for a child that exited having said NOTHING on stdout — no `ready`, no
|
||||
/// `error`, no `ended`. `None` keeps the silent return the UI has always given a clean
|
||||
/// quit: code 0 is the user closing the stream window, and -1 is our own Disconnect/Cancel
|
||||
/// kill (no exit code). Anything else is the session dying before it could speak its
|
||||
/// contract — a missing runtime DLL, a crash, or the wrong binary sitting next to the
|
||||
/// shell — and reporting the code is the difference between a diagnosable failure and a
|
||||
/// connect that silently drops back to the host list.
|
||||
pub(crate) fn silent_exit_banner(code: i32) -> Option<String> {
|
||||
(code != 0 && code != -1).then(|| {
|
||||
format!("The session didn't start (punktfunk-session exited with code {code}). Check the client log.")
|
||||
})
|
||||
}
|
||||
|
||||
/// The session binary: installed next to the shell (the MSIX layout and dev
|
||||
/// `target\…` runs both land on the sibling), else `PATH`.
|
||||
pub(crate) fn session_binary() -> std::path::PathBuf {
|
||||
@@ -220,7 +238,7 @@ fn spawn_with(
|
||||
.and_then(|s| s.code())
|
||||
.unwrap_or(-1);
|
||||
tracing::info!(code, "session binary exited");
|
||||
on_event(SpawnEvent::Exited { error, ended });
|
||||
on_event(SpawnEvent::Exited { error, ended, code });
|
||||
})
|
||||
.map_err(|e| format!("session reader thread: {e}"))?;
|
||||
Ok(())
|
||||
@@ -262,4 +280,17 @@ mod tests {
|
||||
assert!(parse_line("").is_none());
|
||||
assert!(parse_line("{\"other\":1}").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_failing_exit_is_never_blank() {
|
||||
// Clean quit (stream window closed) and our own kill stay silent.
|
||||
assert!(silent_exit_banner(0).is_none());
|
||||
assert!(silent_exit_banner(-1).is_none());
|
||||
// A child that died without speaking the contract names its code — the 0.22.0
|
||||
// regression (a stub session binary exiting 2) showed as a blank bounce to the
|
||||
// host list precisely because nothing filled this in.
|
||||
let banner = silent_exit_banner(2).expect("failing exit must say something");
|
||||
assert!(banner.contains('2'), "{banner}");
|
||||
assert!(silent_exit_banner(101).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +47,17 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Graphics_Direct3D",
|
||||
"Win32_Graphics_Direct3D11",
|
||||
"Win32_Graphics_Direct3D_Fxc",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_Graphics_Dxgi",
|
||||
"Win32_Graphics_Dxgi_Common",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Diagnostics_Etw",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Time",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
|
||||
@@ -54,6 +54,8 @@ use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
@@ -320,11 +322,17 @@ mod cursor_blend;
|
||||
mod cursor_poll;
|
||||
#[path = "idd_push/descriptor.rs"]
|
||||
mod descriptor;
|
||||
// Stall attribution (vdisplay-disturbance-immunity Phase A): the DxgKrnl ETW watch (A.3), the
|
||||
// micro-probe engine (A.2), and the stall watch + verdict matrix that folds them (A.1).
|
||||
#[path = "idd_push/dxgkrnl_etw.rs"]
|
||||
mod dxgkrnl_etw;
|
||||
#[path = "idd_push/probes.rs"]
|
||||
mod probes;
|
||||
#[path = "idd_push/stall.rs"]
|
||||
mod stall;
|
||||
use channel::ChannelBroker;
|
||||
use descriptor::{DescriptorPoller, DisplayDescriptor};
|
||||
use stall::StallWatch;
|
||||
use stall::{StallEvidence, StallWatch};
|
||||
|
||||
pub struct IddPushCapturer {
|
||||
device: ID3D11Device,
|
||||
@@ -482,6 +490,17 @@ pub struct IddPushCapturer {
|
||||
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
||||
/// periodic-stutter diagnostic.
|
||||
stall_watch: StallWatch,
|
||||
/// v2 driver-telemetry trackers feeding [`stall::StallEvidence`]: the header's
|
||||
/// `offered_total` as of the last fresh frame, and the stalest the driver's drain heartbeat
|
||||
/// ever read (µs) while the host starved since then. Rolled at every fresh frame.
|
||||
offered_at_fresh: u64,
|
||||
max_hb_age_us: u64,
|
||||
/// The Phase A.2 micro-probe engine (refcounted process singleton) — its window read rides
|
||||
/// every stall report so the verdict matrix can name the disturbance class.
|
||||
probes: Arc<probes::ProbeEngine>,
|
||||
/// The Phase A.3 DxgKrnl ETW watch; `None` when the session can't start (non-admin dev run)
|
||||
/// — reports then say `etw=unavailable`.
|
||||
etw: Option<Arc<dxgkrnl_etw::EtwWatch>>,
|
||||
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
||||
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
||||
@@ -532,6 +551,47 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Age of a driver-stamped QPC value in microseconds (QPC is system-wide, so cross-process
|
||||
/// comparison is sound); 0 when the stamp reads ahead of us (a benign race with the writer).
|
||||
fn qpc_age_us(stamp: u64) -> u64 {
|
||||
static FREQ: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
let freq = *FREQ.get_or_init(|| {
|
||||
let mut f = 0i64;
|
||||
// SAFETY: plain FFI; `f` is a valid local out-param. The frequency is fixed at boot and
|
||||
// cannot fail on any OS we run on; 0 would only mean the call failed — guarded below.
|
||||
let _ = unsafe { QueryPerformanceFrequency(&mut f) };
|
||||
f.max(0) as u64
|
||||
});
|
||||
if freq == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut now = 0i64;
|
||||
// SAFETY: plain FFI; `now` is a valid local out-param.
|
||||
if unsafe { QueryPerformanceCounter(&mut now) }.is_err() {
|
||||
return 0;
|
||||
}
|
||||
(now as u64).saturating_sub(stamp).saturating_mul(1_000_000) / freq
|
||||
}
|
||||
|
||||
/// The header's v2 telemetry tail — `(drain_heartbeat_qpc, offered_total)`; `None` until a
|
||||
/// telemetry-capable driver writes its first heartbeat (the host always creates the v2 layout,
|
||||
/// so a zero heartbeat means the attached driver predates it).
|
||||
#[inline]
|
||||
fn telemetry(&self) -> Option<(u64, u64)> {
|
||||
// SAFETY: like `latest` — the header stays mapped for the capturer's lifetime, both fields
|
||||
// are 8-aligned `u64`s within the v2 layout the host itself created, and Relaxed suffices
|
||||
// for best-effort diagnostics (the same contract the driver writes them under).
|
||||
let (hb, offered) = unsafe {
|
||||
(
|
||||
(*(std::ptr::addr_of!((*self.header).drain_heartbeat_qpc) as *const AtomicU64))
|
||||
.load(Ordering::Relaxed),
|
||||
(*(std::ptr::addr_of!((*self.header).offered_total) as *const AtomicU64))
|
||||
.load(Ordering::Relaxed),
|
||||
)
|
||||
};
|
||||
(hb != 0).then_some((hb, offered))
|
||||
}
|
||||
|
||||
/// Log the driver's status once it first reports (the only driver-visibility channel we have).
|
||||
fn log_driver_status_once(&mut self) {
|
||||
if self.status_logged {
|
||||
@@ -1380,6 +1440,14 @@ impl IddPushCapturer {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Stall-attribution evidence (v2 telemetry): record the STALEST the driver's drain
|
||||
// heartbeat ever reads between fresh frames. A heartbeat that goes quiet for the hole
|
||||
// convicts our worker (starved/dead WUDFHost); one that stays fresh through it acquits the
|
||||
// driver and indicts the compose/present path. Two Relaxed loads + a QPC read per consume
|
||||
// tick; rolled at every fresh frame below.
|
||||
if let Some((hb, _)) = self.telemetry() {
|
||||
self.max_hb_age_us = self.max_hb_age_us.max(Self::qpc_age_us(hb));
|
||||
}
|
||||
let latest = self.latest();
|
||||
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish
|
||||
// whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
|
||||
@@ -1529,10 +1597,38 @@ impl IddPushCapturer {
|
||||
// the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was
|
||||
// ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two
|
||||
// counters were capturer fields that nothing else touched.
|
||||
self.stall_watch.report(&stall, now);
|
||||
let evidence = StallEvidence {
|
||||
// A publisher re-attach restarts `offered_total` near zero; a ring recreate resets
|
||||
// the stall watch before that can matter, but guard the delta anyway (a restarted
|
||||
// counter reads as "frames offered since the restart", never as a u64 underflow).
|
||||
offered_delta: self.telemetry().map(|(_, offered)| {
|
||||
if offered >= self.offered_at_fresh {
|
||||
offered - self.offered_at_fresh
|
||||
} else {
|
||||
offered
|
||||
}
|
||||
}),
|
||||
max_heartbeat_age_ms: self.max_hb_age_us / 1_000,
|
||||
// The probe + ETW reads span the same window the report's OS-event correlation
|
||||
// uses (the gap plus a lead-in for the disturbance that CAUSED it).
|
||||
probes: now
|
||||
.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| self.probes.window(from, now)),
|
||||
etw: self.etw.as_ref().and_then(|w| {
|
||||
now.checked_sub(stall.gap + Duration::from_millis(300))
|
||||
.map(|from| w.summary(from, now))
|
||||
}),
|
||||
};
|
||||
self.stall_watch.report(&stall, now, &evidence);
|
||||
}
|
||||
if !regen {
|
||||
self.last_fresh = now; // feeds the driver-death watch
|
||||
// A fresh driver frame: feed the driver-death watch and roll the stall-evidence
|
||||
// trackers (a regen re-encodes OLD content — it is not evidence of driver progress).
|
||||
self.last_fresh = now;
|
||||
if let Some((_, offered)) = self.telemetry() {
|
||||
self.offered_at_fresh = offered;
|
||||
}
|
||||
self.max_hb_age_us = 0;
|
||||
}
|
||||
// Build the frame. For PyroWave the encode input is the Y plane
|
||||
// (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence
|
||||
@@ -2037,4 +2133,130 @@ mod tests {
|
||||
"detection re-armed after the reset"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`stall::attribute`]'s verdict table — the Branch-1/Branch-2 fork, per evidence shape.
|
||||
#[test]
|
||||
fn stall_attribution_verdicts() {
|
||||
use super::stall::{attribute, StallVerdict};
|
||||
let verdict = |gap_ms: u64, offered: Option<u64>, hb_age_ms: u64| {
|
||||
attribute(
|
||||
Duration::from_millis(gap_ms),
|
||||
&StallEvidence {
|
||||
offered_delta: offered,
|
||||
max_heartbeat_age_ms: hb_age_ms,
|
||||
probes: None,
|
||||
etw: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
// Pre-telemetry driver: no verdict, whatever the heartbeat tracker read.
|
||||
assert_eq!(verdict(300, None, 500), StallVerdict::NoTelemetry);
|
||||
// Heartbeat silent for most of the hole → the worker starved, wherever the frames were.
|
||||
assert_eq!(verdict(600, Some(0), 400), StallVerdict::WorkerStalled);
|
||||
assert_eq!(verdict(600, Some(50), 300), StallVerdict::WorkerStalled);
|
||||
// A scheduled worker heartbeats every ≤16 ms — 200 ms of silence on a 300 ms gap is under
|
||||
// the max(gap/2, 250 ms) bar, so the verdict falls through to the offered-frames fork.
|
||||
assert_eq!(verdict(300, Some(1), 200), StallVerdict::ComposeSilence);
|
||||
// The stall-ending frame (+ a small resume burst) does not acquit DWM...
|
||||
assert_eq!(verdict(300, Some(3), 20), StallVerdict::ComposeSilence);
|
||||
// ...but sustained composition through the hole does: the frames existed, WE lost them.
|
||||
assert_eq!(verdict(300, Some(8), 20), StallVerdict::DeliveryLeg);
|
||||
assert_eq!(verdict(2_000, Some(120), 30), StallVerdict::DeliveryLeg);
|
||||
// Long holes scale the worker-stalled bar: 900 ms of silence on a 3 s gap is not half.
|
||||
assert_eq!(verdict(3_000, Some(2), 900), StallVerdict::ComposeSilence);
|
||||
assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled);
|
||||
}
|
||||
|
||||
/// [`stall::classify`]'s verdict matrix — how the micro-probe window refines (or declines to
|
||||
/// refine) the driver-telemetry verdict into a named disturbance class.
|
||||
#[test]
|
||||
fn stall_classification_matrix() {
|
||||
use super::stall::{classify, ProbeWindow, StallClass, StallVerdict};
|
||||
let gap = Duration::from_millis(600);
|
||||
let probes = |fence: Option<u64>, dwm: Option<u64>, flush: Option<u64>| ProbeWindow {
|
||||
fence_max_us: fence,
|
||||
dwm_tick_frozen_us: dwm,
|
||||
dwm_flush_max_us: flush,
|
||||
..ProbeWindow::default()
|
||||
};
|
||||
// The driver's own verdicts win outright — probes can't overrule "we lost the frames".
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::WorkerStalled,
|
||||
Some(&probes(Some(500_000), None, None))
|
||||
),
|
||||
StallClass::OursWorker
|
||||
);
|
||||
assert_eq!(
|
||||
classify(gap, &StallVerdict::DeliveryLeg, None),
|
||||
StallClass::OursDelivery
|
||||
);
|
||||
// No probes: compose-silence alone can't name a class.
|
||||
assert_eq!(
|
||||
classify(gap, &StallVerdict::ComposeSilence, None),
|
||||
StallClass::Unattributed
|
||||
);
|
||||
// Fences stalled ≥ gap/2 → the adapter froze — Class 1 (even without driver telemetry).
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(400_000), Some(400_000), None))
|
||||
),
|
||||
StallClass::AdapterFreeze
|
||||
);
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::NoTelemetry,
|
||||
Some(&probes(Some(400_000), None, None))
|
||||
),
|
||||
StallClass::AdapterFreeze
|
||||
);
|
||||
// Fences fine (16 ms round-trips) but DWM's tick froze — Class 2; DwmFlush counts too.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(500_000), None))
|
||||
),
|
||||
StallClass::CompositorBlocked
|
||||
);
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(450_000)))
|
||||
),
|
||||
StallClass::CompositorBlocked
|
||||
);
|
||||
// Everything alive + the driver swears E_PENDING → the frame-generation path.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(Some(16_000), Some(20_000), Some(30_000)))
|
||||
),
|
||||
StallClass::FrameGeneration
|
||||
);
|
||||
// Healthy probes but a pre-telemetry driver: delivery-leg is equally possible — honest.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::NoTelemetry,
|
||||
Some(&probes(Some(16_000), Some(20_000), None))
|
||||
),
|
||||
StallClass::Unattributed
|
||||
);
|
||||
// An absent probe (None) never reads as "stalled" — absence is stated, not guessed.
|
||||
assert_eq!(
|
||||
classify(
|
||||
gap,
|
||||
&StallVerdict::ComposeSilence,
|
||||
Some(&probes(None, Some(20_000), Some(30_000)))
|
||||
),
|
||||
StallClass::FrameGeneration
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Phase A.3 DxgKrnl ETW correlation (stall attribution, `vdisplay-disturbance-immunity.md`
|
||||
//! §4.3): a tiny real-time ETW session on `Microsoft-Windows-DxgKrnl`, event-id-filtered to the
|
||||
//! five display-miniport DDI families whose servicing freezes the present path, so a stall report
|
||||
//! can NAME the DDI (and its duration bracket) instead of saying "below Windows":
|
||||
//!
|
||||
//! - 150/151 `QueryChildStatus` start/stop — connector polling (the DDC/child-I/O class),
|
||||
//! - 272 `IndicateChildStatus` — the miniport itself reporting a connector change,
|
||||
//! - 154/155 `SetPowerState` start/stop — monitor/link power transitions (Class-1 servicing),
|
||||
//! - 430 `SetTimingsFromVidPn` — modeset-class commits (Level-Two "hardware is idle" freezes),
|
||||
//! - 1096/1097 `DisplayDetectControl` start/stop — present on newer builds; filtered in
|
||||
//! unconditionally, harmless where absent.
|
||||
//!
|
||||
//! Kernel-side event-id filtering (`EVENT_FILTER_TYPE_EVENT_ID`) keeps the per-vblank firehose
|
||||
//! off; the session costs a few events per minute. Starting a real-time session needs admin /
|
||||
//! Performance Log Users — the packaged host (service, SYSTEM) has it; a plain dev run degrades
|
||||
//! to `None` and every report says `etw=unavailable` instead of guessing. The session's
|
||||
//! `FlushTimer` is 1 s, so a bracket from the trailing second of a gap can land AFTER that
|
||||
//! stall's report line — the next report (and the metronomic tally) still carries it.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows::core::{GUID, PWSTR};
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::System::Diagnostics::Etw::{
|
||||
CloseTrace, ControlTraceW, EnableTraceEx2, OpenTraceW, ProcessTrace, StartTraceW,
|
||||
CONTROLTRACE_HANDLE, ENABLE_TRACE_PARAMETERS, ENABLE_TRACE_PARAMETERS_VERSION_2,
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER, EVENT_FILTER_DESCRIPTOR, EVENT_FILTER_TYPE_EVENT_ID,
|
||||
EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES,
|
||||
EVENT_TRACE_REAL_TIME_MODE, PROCESSTRACE_HANDLE, PROCESS_TRACE_MODE_EVENT_RECORD,
|
||||
PROCESS_TRACE_MODE_REAL_TIME, TRACE_LEVEL_INFORMATION, WNODE_FLAG_TRACED_GUID,
|
||||
};
|
||||
use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency};
|
||||
|
||||
/// `Microsoft-Windows-DxgKrnl` (`{802EC45A-1E99-4B83-9920-87C98277BA9D}`).
|
||||
const DXGKRNL: GUID = GUID::from_u128(0x802EC45A_1E99_4B83_9920_87C98277BA9D);
|
||||
|
||||
/// The event ids the session filters IN (see the module docs).
|
||||
const FILTER_IDS: [u16; 8] = [150, 151, 272, 154, 155, 430, 1096, 1097];
|
||||
|
||||
/// Session name — ours, stopped-if-stale at start (a crashed host leaves the session behind;
|
||||
/// real-time sessions are machine-global named objects).
|
||||
const SESSION: &str = "punktfunk-stallwatch-dxgkrnl";
|
||||
|
||||
/// The consumer callback's destination: `(event QPC, event id)`, capped. A few events per minute
|
||||
/// in the field; the cap only matters under a detection storm — exactly when the tail is the
|
||||
/// least interesting part.
|
||||
static RING: Mutex<VecDeque<(i64, u16)>> = Mutex::new(VecDeque::new());
|
||||
|
||||
fn qpc_now() -> i64 {
|
||||
let mut v = 0i64;
|
||||
// SAFETY: plain FFI; `v` is a valid local out-param.
|
||||
let _ = unsafe { QueryPerformanceCounter(&mut v) };
|
||||
v
|
||||
}
|
||||
|
||||
fn qpc_freq() -> i64 {
|
||||
static FREQ: OnceLock<i64> = OnceLock::new();
|
||||
*FREQ.get_or_init(|| {
|
||||
let mut f = 0i64;
|
||||
// SAFETY: plain FFI; `f` is a valid local out-param; fixed at boot.
|
||||
let _ = unsafe { QueryPerformanceFrequency(&mut f) };
|
||||
f.max(1)
|
||||
})
|
||||
}
|
||||
|
||||
/// The consumer's per-event callback — record id + QPC timestamp (the session's `ClientContext`
|
||||
/// is 1, so `TimeStamp` IS a QPC value) and return; runs on the consumer thread.
|
||||
unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
|
||||
if record.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `record` is the live event the consumer is delivering for the duration of this call.
|
||||
let (id, ts) = unsafe {
|
||||
(
|
||||
(*record).EventHeader.EventDescriptor.Id,
|
||||
(*record).EventHeader.TimeStamp,
|
||||
)
|
||||
};
|
||||
let mut ring = RING.lock().unwrap();
|
||||
if ring.len() == 2048 {
|
||||
ring.pop_front();
|
||||
}
|
||||
ring.push_back((ts, id));
|
||||
}
|
||||
|
||||
/// A live DxgKrnl watch: the controller handle (stops the session on drop) + the consumer handle.
|
||||
pub(super) struct EtwWatch {
|
||||
session: CONTROLTRACE_HANDLE,
|
||||
consumer: PROCESSTRACE_HANDLE,
|
||||
}
|
||||
|
||||
// SAFETY: both fields are plain kernel handle VALUES (u64 wrappers) owned by this watch; every
|
||||
// operation on them (summary reads the static ring; Drop stops/closes) is thread-safe by the ETW
|
||||
// API contract, and the singleton hands out only `Arc<EtwWatch>`.
|
||||
unsafe impl Send for EtwWatch {}
|
||||
// SAFETY: as above — `&EtwWatch` exposes only `summary` (static-ring reads).
|
||||
unsafe impl Sync for EtwWatch {}
|
||||
|
||||
static WATCH: Mutex<Weak<EtwWatch>> = Mutex::new(Weak::new());
|
||||
|
||||
/// The process-wide watch, started on first use; `None` when the session cannot start (no admin
|
||||
/// rights on a dev run) — callers report `etw=unavailable` rather than guessing.
|
||||
pub(super) fn acquire() -> Option<Arc<EtwWatch>> {
|
||||
let mut g = WATCH.lock().unwrap();
|
||||
if let Some(w) = g.upgrade() {
|
||||
return Some(w);
|
||||
}
|
||||
let w = Arc::new(EtwWatch::start()?);
|
||||
*g = Arc::downgrade(&w);
|
||||
Some(w)
|
||||
}
|
||||
|
||||
/// An `EVENT_TRACE_PROPERTIES` allocation with the session-name space ETW writes into appended —
|
||||
/// the canonical controller-buffer pattern.
|
||||
fn properties_buffer() -> (Vec<u8>, usize) {
|
||||
let base = std::mem::size_of::<EVENT_TRACE_PROPERTIES>();
|
||||
let total = base + (SESSION.len() + 1) * 2;
|
||||
(vec![0u8; total], base)
|
||||
}
|
||||
|
||||
impl EtwWatch {
|
||||
fn start() -> Option<Self> {
|
||||
let name: Vec<u16> = SESSION.encode_utf16().chain([0]).collect();
|
||||
// A stale session from a crashed host blocks StartTrace with ERROR_ALREADY_EXISTS — stop
|
||||
// it by name first (fails benignly when there is none).
|
||||
let (mut stop_buf, _) = properties_buffer();
|
||||
// SAFETY: `stop_buf` is a live, zeroed, correctly-sized properties allocation; the name is
|
||||
// a live nul-terminated wide string; a session handle of 0 + name = control-by-name.
|
||||
unsafe {
|
||||
let props = stop_buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = stop_buf.len() as u32;
|
||||
let _ = ControlTraceW(
|
||||
CONTROLTRACE_HANDLE::default(),
|
||||
PWSTR(name.as_ptr() as *mut _),
|
||||
props,
|
||||
EVENT_TRACE_CONTROL_STOP,
|
||||
);
|
||||
}
|
||||
|
||||
let (mut buf, base) = properties_buffer();
|
||||
let mut session = CONTROLTRACE_HANDLE::default();
|
||||
// SAFETY: `buf` is a live, zeroed allocation of base + name bytes; every write below is a
|
||||
// field of the properties struct at its head; `LoggerNameOffset = base` points at the
|
||||
// appended name space (ETW copies the name there itself). ClientContext 1 = QPC clock —
|
||||
// what makes event timestamps comparable to our probe windows.
|
||||
let rc = unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
(*props).Wnode.Flags = WNODE_FLAG_TRACED_GUID;
|
||||
(*props).Wnode.ClientContext = 1;
|
||||
(*props).LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
|
||||
(*props).BufferSize = 64;
|
||||
(*props).MinimumBuffers = 2;
|
||||
(*props).MaximumBuffers = 4;
|
||||
(*props).FlushTimer = 1;
|
||||
(*props).LoggerNameOffset = base as u32;
|
||||
StartTraceW(&mut session, PWSTR(name.as_ptr() as *mut _), props)
|
||||
};
|
||||
if rc != ERROR_SUCCESS {
|
||||
tracing::debug!(
|
||||
rc = rc.0,
|
||||
"DxgKrnl ETW session unavailable (needs admin / Performance Log Users) — \
|
||||
stall reports will say etw=unavailable"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Enable DxgKrnl with a kernel-side event-id filter — the whole point: the provider's
|
||||
// vblank/DPC keywords never reach us.
|
||||
let mut filter = Vec::with_capacity(4 + FILTER_IDS.len() * 2);
|
||||
filter.extend_from_slice(&[1u8, 0u8]); // FilterIn = TRUE, Reserved
|
||||
filter.extend_from_slice(&(FILTER_IDS.len() as u16).to_le_bytes());
|
||||
for id in FILTER_IDS {
|
||||
filter.extend_from_slice(&id.to_le_bytes());
|
||||
}
|
||||
let mut desc = EVENT_FILTER_DESCRIPTOR {
|
||||
Ptr: filter.as_ptr() as u64,
|
||||
Size: filter.len() as u32,
|
||||
Type: EVENT_FILTER_TYPE_EVENT_ID,
|
||||
};
|
||||
let params = ENABLE_TRACE_PARAMETERS {
|
||||
Version: ENABLE_TRACE_PARAMETERS_VERSION_2,
|
||||
EnableProperty: 0,
|
||||
ControlFlags: 0,
|
||||
SourceId: GUID::zeroed(),
|
||||
EnableFilterDesc: &mut desc,
|
||||
FilterDescCount: 1,
|
||||
};
|
||||
// SAFETY: `session` is the live handle just started; `params`/`desc`/`filter` are live
|
||||
// locals for this synchronous call (the kernel copies the filter).
|
||||
let rc = unsafe {
|
||||
EnableTraceEx2(
|
||||
session,
|
||||
&DXGKRNL,
|
||||
EVENT_CONTROL_CODE_ENABLE_PROVIDER.0,
|
||||
TRACE_LEVEL_INFORMATION as u8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(¶ms),
|
||||
)
|
||||
};
|
||||
if rc != ERROR_SUCCESS {
|
||||
tracing::debug!(
|
||||
rc = rc.0,
|
||||
"DxgKrnl ETW enable failed — stopping the session"
|
||||
);
|
||||
let (mut buf, _) = properties_buffer();
|
||||
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
|
||||
unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
let _ = ControlTraceW(session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut log = EVENT_TRACE_LOGFILEW {
|
||||
LoggerName: PWSTR(name.as_ptr() as *mut _),
|
||||
..Default::default()
|
||||
};
|
||||
log.Anonymous1.ProcessTraceMode =
|
||||
PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
|
||||
log.Anonymous2.EventRecordCallback = Some(on_event);
|
||||
// SAFETY: `log` is a fully-initialized local; `name` outlives the call (OpenTrace copies
|
||||
// what it needs before returning).
|
||||
let consumer = unsafe { OpenTraceW(&mut log) };
|
||||
if consumer.Value == u64::MAX {
|
||||
tracing::debug!("DxgKrnl ETW OpenTrace failed — stopping the session");
|
||||
let (mut buf, _) = properties_buffer();
|
||||
// SAFETY: live handle + valid properties allocation, stopped exactly once on this path.
|
||||
unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
let _ = ControlTraceW(session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// The consumer: ProcessTrace blocks for the session's lifetime; Drop's STOP unblocks it.
|
||||
let consumer_value = consumer.Value;
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("pf-etw-dxgkrnl".into())
|
||||
.spawn(move || {
|
||||
// SAFETY: `consumer_value` is the live consumer handle opened above; ProcessTrace
|
||||
// pumps it until the controller stops the session (Drop), then returns.
|
||||
let rc = unsafe {
|
||||
ProcessTrace(
|
||||
&[PROCESSTRACE_HANDLE {
|
||||
Value: consumer_value,
|
||||
}],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
};
|
||||
tracing::debug!(rc = rc.0, "DxgKrnl ETW consumer exited");
|
||||
})
|
||||
{
|
||||
tracing::debug!(error = %e, "DxgKrnl ETW consumer thread failed to spawn");
|
||||
let (mut buf, _) = properties_buffer();
|
||||
// SAFETY: live handles + valid properties allocation, released exactly once on this path.
|
||||
unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
let _ = ControlTraceW(session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
|
||||
let _ = CloseTrace(consumer);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
tracing::debug!("DxgKrnl ETW stall-watch session live (event-id filtered)");
|
||||
Some(Self { session, consumer })
|
||||
}
|
||||
|
||||
/// Summarize the DDI activity inside `[from, to]` — the correlation line a stall report
|
||||
/// carries. Brackets that merely SPAN the window count too (a freeze-long `SetPowerState`
|
||||
/// has both edges outside the hole it caused). `"none"` when the window is clean.
|
||||
pub(super) fn summary(&self, from: Instant, to: Instant) -> String {
|
||||
// Instant → QPC: anchor both clocks now and offset backwards.
|
||||
let (now_i, now_q, freq) = (Instant::now(), qpc_now(), qpc_freq());
|
||||
let to_q = now_q - duration_qpc(now_i.saturating_duration_since(to), freq);
|
||||
let from_q = now_q - duration_qpc(now_i.saturating_duration_since(from), freq);
|
||||
let events: Vec<(i64, u16)> = {
|
||||
let ring = RING.lock().unwrap();
|
||||
ring.iter().filter(|(ts, _)| *ts <= to_q).copied().collect()
|
||||
};
|
||||
let ms = |dq: i64| dq.max(0) * 1_000 / freq;
|
||||
let mut parts = Vec::new();
|
||||
for (start_id, stop_id, label) in [
|
||||
(150u16, 151u16, "QueryChildStatus"),
|
||||
(154, 155, "SetPowerState"),
|
||||
(1096, 1097, "DisplayDetectControl"),
|
||||
] {
|
||||
let mut open: Option<i64> = None;
|
||||
let mut count = 0u32;
|
||||
let mut max_ms = 0i64;
|
||||
let mut still_open = false;
|
||||
for &(ts, id) in &events {
|
||||
if id == start_id {
|
||||
open = Some(ts);
|
||||
} else if id == stop_id {
|
||||
if let Some(s) = open.take() {
|
||||
// The bracket [s, ts] counts when it intersects the window.
|
||||
if s <= to_q && ts >= from_q {
|
||||
count += 1;
|
||||
max_ms = max_ms.max(ms(ts - s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = open {
|
||||
if s <= to_q {
|
||||
count += 1;
|
||||
max_ms = max_ms.max(ms(to_q - s));
|
||||
still_open = true;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
parts.push(format!(
|
||||
"{label}×{count}(max {max_ms}ms{})",
|
||||
if still_open { ", open" } else { "" }
|
||||
));
|
||||
}
|
||||
}
|
||||
for (id, label) in [
|
||||
(272u16, "IndicateChildStatus"),
|
||||
(430, "SetTimingsFromVidPn"),
|
||||
] {
|
||||
let count = events
|
||||
.iter()
|
||||
.filter(|(ts, i)| *i == id && *ts >= from_q && *ts <= to_q)
|
||||
.count();
|
||||
if count > 0 {
|
||||
parts.push(format!("{label}×{count}"));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Duration` in QPC ticks (saturating; diagnostic precision).
|
||||
fn duration_qpc(d: Duration, freq: i64) -> i64 {
|
||||
(d.as_micros() as i64).saturating_mul(freq) / 1_000_000
|
||||
}
|
||||
|
||||
impl Drop for EtwWatch {
|
||||
fn drop(&mut self) {
|
||||
let (mut buf, _) = properties_buffer();
|
||||
// SAFETY: `self.session`/`self.consumer` are the live handles this watch owns; the STOP
|
||||
// (with a valid properties allocation) ends the session and unblocks ProcessTrace, and
|
||||
// CloseTrace releases the consumer — each exactly once, here.
|
||||
unsafe {
|
||||
let props = buf.as_mut_ptr().cast::<EVENT_TRACE_PROPERTIES>();
|
||||
(*props).Wnode.BufferSize = buf.len() as u32;
|
||||
let _ = ControlTraceW(self.session, PWSTR::null(), props, EVENT_TRACE_CONTROL_STOP);
|
||||
let _ = CloseTrace(self.consumer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,6 +633,10 @@ impl IddPushCapturer {
|
||||
last_liveness: Instant::now(),
|
||||
last_kick: Instant::now(),
|
||||
stall_watch: StallWatch::new(),
|
||||
offered_at_fresh: 0,
|
||||
max_hb_age_us: 0,
|
||||
probes: super::probes::acquire(),
|
||||
etw: super::dxgkrnl_etw::acquire(),
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
video_conv: None,
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Phase A.2 micro-probes (stall attribution, `vdisplay-disturbance-immunity.md` §4.2): a small
|
||||
//! engine of watchdogged sacrificial threads that continuously measure the legs a capture stall
|
||||
//! could hide in, so the stall reporter can read back "what was alive during the hole":
|
||||
//!
|
||||
//! - **fence** (one thread per hardware adapter): a tiny CopyResource + D3D11 fence round-trip —
|
||||
//! engine liveness. A Level-Two/Three adapter freeze ("graphics hardware is idle") stalls this
|
||||
//! for the freeze's duration on EVERY engine of that adapter.
|
||||
//! - **dwm-tick**: `DwmGetCompositionTimingInfo(NULL)` `cRefresh` advance — the compositor's own
|
||||
//! clock. Frozen tick with live fences = DWM (or something it waits on) is blocked, not the GPU.
|
||||
//! - **dwm-flush**: a watchdogged `DwmFlush` — its latency IS the composition-wait measurement.
|
||||
//! - **scanline**: `D3DKMTGetScanLine` on an active output (Level-Zero reentrant — documented safe
|
||||
//! against every miniport lock, so a BLOCKED call here convicts the KMD itself). Prefers a
|
||||
//! physical head; on an exclusive topology only our IDD is active and the call's latency is
|
||||
//! still the KMD-liveness signal ([`pf_win_display::win_display::active_scanline_target`]).
|
||||
//! - **cpu**: a high-res sleeper measuring overshoot — the DPC-storm / CPU-starvation
|
||||
//! discriminator (a machine-wide scheduling hole inflates every other probe too).
|
||||
//!
|
||||
//! The blocking of a probe is its measurement — none of these ever run on the capture/encode
|
||||
//! path. Threads are DETACHED (never joined): a probe wedged inside a kernel call for the stall's
|
||||
//! duration exits at its next loop turn after the stop flag flips. One engine per process
|
||||
//! ([`acquire`]), refcounted across parallel capturers; probes sample at 20 Hz or slower and cost
|
||||
//! microseconds each, so the engine is invisible next to a streaming session.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows::core::{s, w, Interface};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, HMODULE, HWND, LUID};
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, ID3D11Device5, ID3D11DeviceContext4, ID3D11Fence, ID3D11Texture2D,
|
||||
D3D11_CREATE_DEVICE_FLAG, D3D11_FENCE_FLAG_NONE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE_DEFAULT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dwm::{DwmFlush, DwmGetCompositionTimingInfo, DWM_TIMING_INFO};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress};
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||
|
||||
use super::stall::ProbeWindow;
|
||||
|
||||
/// One probe's sample ring: `(completed_at, span, value_us)` — `value` is the measurement (a call
|
||||
/// latency or a frozen-span/overshoot), `span` the wall interval it describes ending at
|
||||
/// `completed_at`. Capped; ~20 Hz per probe → several minutes of coverage.
|
||||
struct Ring {
|
||||
samples: Mutex<VecDeque<(Instant, Duration, u64)>>,
|
||||
}
|
||||
|
||||
impl Ring {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
samples: Mutex::new(VecDeque::with_capacity(512)),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&self, completed_at: Instant, span: Duration, value_us: u64) {
|
||||
let mut s = self.samples.lock().unwrap();
|
||||
if s.len() == 512 {
|
||||
s.pop_front();
|
||||
}
|
||||
s.push_back((completed_at, span, value_us));
|
||||
}
|
||||
|
||||
/// Max `value` among samples whose described interval `[completed_at - span, completed_at]`
|
||||
/// intersects `[from, to]`; `None` when the ring holds nothing for the window (probe absent,
|
||||
/// or it started after the window).
|
||||
fn window_max(&self, from: Instant, to: Instant) -> Option<u64> {
|
||||
let s = self.samples.lock().unwrap();
|
||||
let mut max: Option<u64> = None;
|
||||
for (end, span, value) in s.iter() {
|
||||
let start = end.checked_sub(*span).unwrap_or(*end);
|
||||
if start <= to && *end >= from {
|
||||
max = Some(max.map_or(*value, |m| m.max(*value)));
|
||||
}
|
||||
}
|
||||
max
|
||||
}
|
||||
}
|
||||
|
||||
/// A blocking probe's "currently inside the call since" marker: a call still blocked when the
|
||||
/// reporter reads the window is exactly the interesting case, and its ring sample does not exist
|
||||
/// yet — the marker's age stands in for it.
|
||||
struct InFlight {
|
||||
since: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl InFlight {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
since: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter(&self) -> Instant {
|
||||
let now = Instant::now();
|
||||
*self.since.lock().unwrap() = Some(now);
|
||||
now
|
||||
}
|
||||
|
||||
fn exit(&self) {
|
||||
*self.since.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Age (µs) of a call still in flight that started before `to`; 0 when idle.
|
||||
fn blocked_us(&self, to: Instant) -> u64 {
|
||||
match *self.since.lock().unwrap() {
|
||||
Some(since) if since <= to => to.duration_since(since).as_micros() as u64,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A blocking probe = ring + in-flight marker; `window_max` folds both.
|
||||
struct BlockingProbe {
|
||||
ring: Ring,
|
||||
inflight: InFlight,
|
||||
}
|
||||
|
||||
impl BlockingProbe {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
ring: Ring::new(),
|
||||
inflight: InFlight::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure(&self, f: impl FnOnce()) {
|
||||
let t0 = self.inflight.enter();
|
||||
f();
|
||||
let dur = t0.elapsed();
|
||||
self.inflight.exit();
|
||||
self.ring.push(Instant::now(), dur, dur.as_micros() as u64);
|
||||
}
|
||||
|
||||
fn window_max(&self, from: Instant, to: Instant) -> Option<u64> {
|
||||
let ring = self.ring.window_max(from, to);
|
||||
let blocked = self.inflight.blocked_us(to);
|
||||
match (ring, blocked) {
|
||||
(None, 0) => None,
|
||||
(r, b) => Some(r.unwrap_or(0).max(b)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
stop: AtomicBool,
|
||||
/// One per hardware adapter, labelled by LUID (hybrids run two).
|
||||
fences: Vec<(String, BlockingProbe)>,
|
||||
dwm_tick: Ring,
|
||||
dwm_flush: BlockingProbe,
|
||||
scanline: BlockingProbe,
|
||||
/// Whether the scanline probe currently targets a PHYSICAL head (see the module docs).
|
||||
scanline_physical: AtomicBool,
|
||||
scanline_running: AtomicBool,
|
||||
cpu: Ring,
|
||||
}
|
||||
|
||||
/// The process-wide micro-probe engine. Hold the `Arc` for the session's lifetime; the last drop
|
||||
/// stops every thread at its next loop turn.
|
||||
pub(super) struct ProbeEngine {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl Drop for ProbeEngine {
|
||||
fn drop(&mut self) {
|
||||
self.inner.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
static ENGINE: Mutex<Weak<ProbeEngine>> = Mutex::new(Weak::new());
|
||||
|
||||
/// The engine, started on first use and shared across parallel capturers (probe threads are
|
||||
/// per-PROCESS facts — two capturers asking the same questions twice would only add noise).
|
||||
pub(super) fn acquire() -> Arc<ProbeEngine> {
|
||||
let mut g = ENGINE.lock().unwrap();
|
||||
if let Some(e) = g.upgrade() {
|
||||
return e;
|
||||
}
|
||||
let e = Arc::new(ProbeEngine::start());
|
||||
*g = Arc::downgrade(&e);
|
||||
e
|
||||
}
|
||||
|
||||
impl ProbeEngine {
|
||||
/// What the probes saw across `[from, to]` — the stall reporter's evidence read. Cheap: five
|
||||
/// short mutex-held ring scans.
|
||||
pub(super) fn window(&self, from: Instant, to: Instant) -> ProbeWindow {
|
||||
let i = &self.inner;
|
||||
ProbeWindow {
|
||||
fence_max_us: i
|
||||
.fences
|
||||
.iter()
|
||||
.filter_map(|(_, p)| p.window_max(from, to))
|
||||
.max(),
|
||||
dwm_tick_frozen_us: i.dwm_tick.window_max(from, to),
|
||||
dwm_flush_max_us: i.dwm_flush.window_max(from, to),
|
||||
scanline_max_us: if i.scanline_running.load(Ordering::Relaxed) {
|
||||
i.scanline.window_max(from, to)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
scanline_physical: i.scanline_physical.load(Ordering::Relaxed),
|
||||
cpu_max_overshoot_us: i.cpu.window_max(from, to),
|
||||
}
|
||||
}
|
||||
|
||||
fn start() -> Self {
|
||||
let fences = enumerate_hardware_adapters()
|
||||
.into_iter()
|
||||
.map(|(label, _)| (label, BlockingProbe::new()))
|
||||
.collect();
|
||||
let inner = Arc::new(Inner {
|
||||
stop: AtomicBool::new(false),
|
||||
fences,
|
||||
dwm_tick: Ring::new(),
|
||||
dwm_flush: BlockingProbe::new(),
|
||||
scanline: BlockingProbe::new(),
|
||||
scanline_physical: AtomicBool::new(false),
|
||||
scanline_running: AtomicBool::new(false),
|
||||
cpu: Ring::new(),
|
||||
});
|
||||
// Fence probes re-enumerate to pair each adapter with its probe slot by index (the two
|
||||
// enumerations run back-to-back; a hot-plugged GPU between them only skips a probe).
|
||||
for (idx, (label, adapter)) in enumerate_hardware_adapters().into_iter().enumerate() {
|
||||
let inner_t = inner.clone();
|
||||
spawn_detached(&format!("pf-probe-fence-{idx}"), move || {
|
||||
fence_probe_loop(&inner_t, idx, &label, &adapter);
|
||||
});
|
||||
}
|
||||
let inner_t = inner.clone();
|
||||
spawn_detached("pf-probe-dwm-tick", move || dwm_tick_loop(&inner_t));
|
||||
let inner_t = inner.clone();
|
||||
spawn_detached("pf-probe-dwm-flush", move || dwm_flush_loop(&inner_t));
|
||||
let inner_t = inner.clone();
|
||||
spawn_detached("pf-probe-scanline", move || scanline_loop(&inner_t));
|
||||
let inner_t = inner.clone();
|
||||
spawn_detached("pf-probe-cpu", move || cpu_sentinel_loop(&inner_t));
|
||||
tracing::debug!(
|
||||
fence_probes = inner.fences.len(),
|
||||
"stall-attribution micro-probes started (fence/dwm-tick/dwm-flush/scanline/cpu)"
|
||||
);
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a detached probe thread — never joined by design (see the module docs).
|
||||
fn spawn_detached(name: &str, f: impl FnOnce() + Send + 'static) {
|
||||
if let Err(e) = std::thread::Builder::new().name(name.into()).spawn(f) {
|
||||
tracing::debug!(name, error = %e, "micro-probe thread failed to spawn — probe absent");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hardware (non-software) DXGI adapters, labelled by LUID. Display-only/indirect adapters are
|
||||
/// filtered later by their device-creation failure, not here.
|
||||
fn enumerate_hardware_adapters() -> Vec<(String, IDXGIAdapter1)> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: plain factory creation; the result is checked.
|
||||
let Ok(factory) = (unsafe { CreateDXGIFactory1::<IDXGIFactory1>() }) else {
|
||||
return out;
|
||||
};
|
||||
for i in 0.. {
|
||||
// SAFETY: `factory` is live; enumeration ends at DXGI_ERROR_NOT_FOUND (the Err arm).
|
||||
let Ok(adapter) = (unsafe { factory.EnumAdapters1(i) }) else {
|
||||
break;
|
||||
};
|
||||
// SAFETY: `adapter` is live; `desc` is a valid out-param.
|
||||
let Ok(desc) = (unsafe { adapter.GetDesc1() }) else {
|
||||
continue;
|
||||
};
|
||||
if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 != 0 {
|
||||
continue;
|
||||
}
|
||||
out.push((
|
||||
format!(
|
||||
"{:08x}:{:08x}",
|
||||
desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||
),
|
||||
adapter,
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One adapter's engine-liveness loop: submit a trivial copy, signal a fence, wait on its event.
|
||||
/// The wait latency is the sample. Device-creation failure = probe absent for this adapter
|
||||
/// (display-only/indirect adapters land here).
|
||||
fn fence_probe_loop(inner: &Inner, idx: usize, label: &str, adapter: &IDXGIAdapter1) {
|
||||
let Some((context, ctx4, fence, event, (a, b))) = fence_probe_setup(adapter) else {
|
||||
tracing::debug!(
|
||||
adapter = label,
|
||||
"fence probe: no device on this adapter — absent"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut value = 0u64;
|
||||
let Some((_, probe)) = inner.fences.get(idx) else {
|
||||
return;
|
||||
};
|
||||
while !inner.stop.load(Ordering::Relaxed) {
|
||||
value += 1;
|
||||
probe.measure(|| {
|
||||
// SAFETY: all COM objects are live for this loop's lifetime (owned above); `a`/`b`
|
||||
// are same-device, same-desc textures; the event handle is ours. The fence signal
|
||||
// orders after the queued copy, so the wait measures the engine actually processing
|
||||
// work; the 10 s ceiling only bounds a truly wedged adapter (the timeout sample still
|
||||
// records — that IS the measurement).
|
||||
unsafe {
|
||||
context.CopyResource(&a, &b);
|
||||
let _ = ctx4.Signal(&fence, value);
|
||||
if fence.SetEventOnCompletion(value, event).is_ok() {
|
||||
let _ = WaitForSingleObject(event, 10_000);
|
||||
}
|
||||
}
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
// SAFETY: the loop exited; this thread owns `event` and closes it exactly once.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fence probe's D3D plumbing: a device on `adapter`, its fence, the wait event, and two tiny
|
||||
/// textures kept alive for the copies. `None` when any piece is unavailable (pre-FL11 or
|
||||
/// display-only adapters, fence-less runtimes).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn fence_probe_setup(
|
||||
adapter: &IDXGIAdapter1,
|
||||
) -> Option<(
|
||||
windows::Win32::Graphics::Direct3D11::ID3D11DeviceContext,
|
||||
ID3D11DeviceContext4,
|
||||
ID3D11Fence,
|
||||
HANDLE,
|
||||
(ID3D11Texture2D, ID3D11Texture2D),
|
||||
)> {
|
||||
let mut device = None;
|
||||
let mut context = None;
|
||||
// SAFETY: adapter is live; UNKNOWN driver type is the documented mode for an explicit
|
||||
// adapter; out-params are valid locals checked below.
|
||||
unsafe {
|
||||
D3D11CreateDevice(
|
||||
adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_FLAG(0),
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.ok()?;
|
||||
}
|
||||
let device = device?;
|
||||
let context = context?;
|
||||
let device5: ID3D11Device5 = device.cast().ok()?;
|
||||
let ctx4: ID3D11DeviceContext4 = context.cast().ok()?;
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: 16,
|
||||
Height: 16,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = None;
|
||||
let mut b = None;
|
||||
// SAFETY: `device` is live, `desc` fully initialized, out-params valid locals checked below.
|
||||
unsafe {
|
||||
device.CreateTexture2D(&desc, None, Some(&mut a)).ok()?;
|
||||
device.CreateTexture2D(&desc, None, Some(&mut b)).ok()?;
|
||||
}
|
||||
let (a, b) = (a?, b?);
|
||||
let mut fence = None;
|
||||
// SAFETY: `device5` is live; out-param is a valid local checked below.
|
||||
unsafe {
|
||||
device5
|
||||
.CreateFence(0, D3D11_FENCE_FLAG_NONE, &mut fence)
|
||||
.ok()?;
|
||||
}
|
||||
let fence: ID3D11Fence = fence?;
|
||||
// SAFETY: plain event creation (auto-reset, unnamed); checked below, closed by the loop.
|
||||
let event = unsafe { CreateEventW(None, false, false, None) }.ok()?;
|
||||
Some((context, ctx4, fence, event, (a, b)))
|
||||
}
|
||||
|
||||
/// `cRefresh` advance sampling: each tick records how long the compositor's frame counter has been
|
||||
/// unchanged. A frozen span covering a stall (with live fences) = DWM blocked, not the GPU.
|
||||
fn dwm_tick_loop(inner: &Inner) {
|
||||
let mut last_refresh = 0u64;
|
||||
let mut last_change = Instant::now();
|
||||
while !inner.stop.load(Ordering::Relaxed) {
|
||||
let mut info = DWM_TIMING_INFO {
|
||||
cbSize: std::mem::size_of::<DWM_TIMING_INFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: a NULL hwnd asks for composition-wide timing; `info` is a valid local with
|
||||
// `cbSize` stamped (the API's byte-count contract).
|
||||
if unsafe { DwmGetCompositionTimingInfo(HWND::default(), &mut info) }.is_ok() {
|
||||
let now = Instant::now();
|
||||
if info.cRefresh != last_refresh {
|
||||
last_refresh = info.cRefresh;
|
||||
last_change = now;
|
||||
}
|
||||
let frozen = now.duration_since(last_change);
|
||||
inner.dwm_tick.push(now, frozen, frozen.as_micros() as u64);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Watchdogged `DwmFlush`: blocks until the next composition — the wait IS the measurement.
|
||||
fn dwm_flush_loop(inner: &Inner) {
|
||||
while !inner.stop.load(Ordering::Relaxed) {
|
||||
inner.dwm_flush.measure(|| {
|
||||
// SAFETY: no arguments, no state — the call blocks until DWM composes (or fails
|
||||
// immediately when composition is unavailable; both durations are valid samples).
|
||||
let _ = unsafe { DwmFlush() };
|
||||
});
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// `D3DKMT_OPENADAPTERFROMLUID` (d3dkmthk.h): LUID in, kernel adapter handle out.
|
||||
#[repr(C)]
|
||||
struct D3dkmtOpenAdapterFromLuid {
|
||||
adapter_luid: LUID,
|
||||
h_adapter: u32,
|
||||
}
|
||||
|
||||
/// `D3DKMT_GETSCANLINE` (d3dkmthk.h): `InVerticalBlank` is a C `BOOLEAN` (u8) — the pad bytes
|
||||
/// before the 4-aligned `scan_line` mirror the C layout exactly (size assert below).
|
||||
#[repr(C)]
|
||||
struct D3dkmtGetScanline {
|
||||
h_adapter: u32,
|
||||
vidpn_source_id: u32,
|
||||
in_vertical_blank: u8,
|
||||
_pad: [u8; 3],
|
||||
scan_line: u32,
|
||||
}
|
||||
|
||||
/// `D3DKMT_CLOSEADAPTER` (d3dkmthk.h).
|
||||
#[repr(C)]
|
||||
struct D3dkmtCloseAdapter {
|
||||
h_adapter: u32,
|
||||
}
|
||||
|
||||
const _: () = {
|
||||
assert!(std::mem::size_of::<D3dkmtOpenAdapterFromLuid>() == 12);
|
||||
assert!(std::mem::size_of::<D3dkmtGetScanline>() == 16);
|
||||
assert!(std::mem::size_of::<D3dkmtCloseAdapter>() == 4);
|
||||
};
|
||||
|
||||
type D3dkmtFn<T> = unsafe extern "system" fn(*mut T) -> i32;
|
||||
|
||||
/// Resolve a `D3DKMT*` entry point from gdi32 (no stable windows-rs bindings — the same
|
||||
/// GetProcAddress route as pf-frame's scheduling-priority call).
|
||||
///
|
||||
/// # Safety
|
||||
/// `T` must be the exact d3dkmthk.h argument struct for `name` — the transmute binds the
|
||||
/// signature, and the layout asserts above pin the three structs this module uses.
|
||||
unsafe fn d3dkmt<T>(name: windows::core::PCSTR) -> Option<D3dkmtFn<T>> {
|
||||
// SAFETY: gdi32 is a permanent system module in any process that touched GDI/D3D; the name is
|
||||
// a static nul-terminated literal from the caller.
|
||||
let gdi32 = unsafe { GetModuleHandleW(w!("gdi32.dll")) }.ok()?;
|
||||
// SAFETY: `gdi32` is the live module handle just obtained.
|
||||
let p = unsafe { GetProcAddress(gdi32, name) }?;
|
||||
// SAFETY: the caller's contract (doc above) — `p` is the named export, whose ABI is
|
||||
// `NTSTATUS fn(struct*)` for every D3DKMT entry point this module names.
|
||||
Some(unsafe { std::mem::transmute::<unsafe extern "system" fn() -> isize, D3dkmtFn<T>>(p) })
|
||||
}
|
||||
|
||||
/// Level-Zero KMD-liveness loop: `D3DKMTGetScanLine` against an active output, retargeted every
|
||||
/// ~2 s (topology moves mid-session). The call's LATENCY is the signal; a blocked Level-Zero DDI
|
||||
/// convicts the KMD below every OS lever.
|
||||
fn scanline_loop(inner: &Inner) {
|
||||
// SAFETY: `D3dkmtOpenAdapterFromLuid` is the exact d3dkmthk.h struct for this export
|
||||
// (layout-asserted above) — the `d3dkmt` safety contract.
|
||||
let open = unsafe { d3dkmt::<D3dkmtOpenAdapterFromLuid>(s!("D3DKMTOpenAdapterFromLuid")) };
|
||||
// SAFETY: `D3dkmtGetScanline` is the exact d3dkmthk.h struct for this export (asserted above).
|
||||
let get = unsafe { d3dkmt::<D3dkmtGetScanline>(s!("D3DKMTGetScanLine")) };
|
||||
// SAFETY: `D3dkmtCloseAdapter` is the exact d3dkmthk.h struct for this export (asserted above).
|
||||
let close = unsafe { d3dkmt::<D3dkmtCloseAdapter>(s!("D3DKMTCloseAdapter")) };
|
||||
let (Some(open), Some(get), Some(close)) = (open, get, close) else {
|
||||
tracing::debug!("scanline probe: D3DKMT entry points unavailable — absent");
|
||||
return;
|
||||
};
|
||||
let mut target: Option<(u32, u32)> = None; // (h_adapter, vidpn_source_id)
|
||||
let mut ticks = 0u32;
|
||||
while !inner.stop.load(Ordering::Relaxed) {
|
||||
if target.is_none() || ticks % 20 == 0 {
|
||||
if let Some((h, _)) = target.take() {
|
||||
let mut req = D3dkmtCloseAdapter { h_adapter: h };
|
||||
// SAFETY: `req` is a valid local for the asserted layout; `h` came from a
|
||||
// successful open below and is closed exactly once here.
|
||||
unsafe { close(&mut req) };
|
||||
}
|
||||
if let Some((lo, hi, source, physical)) =
|
||||
pf_win_display::win_display::active_scanline_target()
|
||||
{
|
||||
let mut req = D3dkmtOpenAdapterFromLuid {
|
||||
adapter_luid: LUID {
|
||||
LowPart: lo,
|
||||
HighPart: hi,
|
||||
},
|
||||
h_adapter: 0,
|
||||
};
|
||||
// SAFETY: `req` is a valid local for the asserted layout; the returned handle is
|
||||
// owned by this loop and closed on retarget/exit.
|
||||
if unsafe { open(&mut req) } >= 0 && req.h_adapter != 0 {
|
||||
target = Some((req.h_adapter, source));
|
||||
inner.scanline_physical.store(physical, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
inner
|
||||
.scanline_running
|
||||
.store(target.is_some(), Ordering::Relaxed);
|
||||
}
|
||||
ticks = ticks.wrapping_add(1);
|
||||
if let Some((h, source)) = target {
|
||||
inner.scanline.measure(|| {
|
||||
let mut req = D3dkmtGetScanline {
|
||||
h_adapter: h,
|
||||
vidpn_source_id: source,
|
||||
in_vertical_blank: 0,
|
||||
_pad: [0; 3],
|
||||
scan_line: 0,
|
||||
};
|
||||
// SAFETY: `req` is a valid local for the asserted layout; `h` is the live adapter
|
||||
// handle owned by this loop. A failed status is still a timed (valid) sample.
|
||||
unsafe { get(&mut req) };
|
||||
});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if let Some((h, _)) = target.take() {
|
||||
let mut req = D3dkmtCloseAdapter { h_adapter: h };
|
||||
// SAFETY: as the retarget close above — owned handle, closed exactly once at exit.
|
||||
unsafe { close(&mut req) };
|
||||
}
|
||||
}
|
||||
|
||||
/// DPC/starvation sentinel: 5 ms sleeps, overshoot aggregated to one max per ~100 ms bucket.
|
||||
fn cpu_sentinel_loop(inner: &Inner) {
|
||||
let mut bucket_max = 0u64;
|
||||
let mut bucket_start = Instant::now();
|
||||
while !inner.stop.load(Ordering::Relaxed) {
|
||||
let t0 = Instant::now();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
let overshoot = t0.elapsed().saturating_sub(Duration::from_millis(5));
|
||||
bucket_max = bucket_max.max(overshoot.as_micros() as u64);
|
||||
let now = Instant::now();
|
||||
let span = now.duration_since(bucket_start);
|
||||
if span >= Duration::from_millis(100) {
|
||||
inner.cpu.push(now, span, bucket_max);
|
||||
bucket_max = 0;
|
||||
bucket_start = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,194 @@ pub(super) struct Stall {
|
||||
pub(super) metronomic: Option<Duration>,
|
||||
}
|
||||
|
||||
/// Driver-telemetry evidence for one stall window (the v2 header tail — see
|
||||
/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap
|
||||
/// frame and the frame that ended the stall.
|
||||
pub(super) struct StallEvidence {
|
||||
/// Surfaces the driver OFFERED to the ring publisher during the window (delta of
|
||||
/// `offered_total`); `None` = pre-telemetry driver (it never wrote the tail).
|
||||
pub(super) offered_delta: Option<u64>,
|
||||
/// The STALEST the driver's drain heartbeat ever read while the host starved (max of
|
||||
/// now − heartbeat over the window), in milliseconds.
|
||||
pub(super) max_heartbeat_age_ms: u64,
|
||||
/// What the micro-probe engine saw across the window (Phase A.2); `None` when the engine
|
||||
/// isn't running.
|
||||
pub(super) probes: Option<ProbeWindow>,
|
||||
/// The DxgKrnl DDI activity inside the window (Phase A.3 ETW summary); `None` when the
|
||||
/// session is unavailable (non-admin dev run).
|
||||
pub(super) etw: Option<String>,
|
||||
}
|
||||
|
||||
/// The micro-probes' window read (Phase A.2, built by `probes::ProbeEngine::window`): per-leg
|
||||
/// maxima across one stall window. Every field is `None` when that probe is absent (no adapter
|
||||
/// device, no active output, thread failed to spawn) — absence is stated, never guessed.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct ProbeWindow {
|
||||
/// Worst engine-liveness fence round-trip (µs) across all hardware adapters.
|
||||
pub(super) fence_max_us: Option<u64>,
|
||||
/// Longest span (µs) with no `DwmGetCompositionTimingInfo` `cRefresh` advance.
|
||||
pub(super) dwm_tick_frozen_us: Option<u64>,
|
||||
/// Worst watchdogged `DwmFlush` latency (µs).
|
||||
pub(super) dwm_flush_max_us: Option<u64>,
|
||||
/// Worst `D3DKMTGetScanLine` CALL latency (µs) — Level-Zero, so blocking convicts the KMD.
|
||||
pub(super) scanline_max_us: Option<u64>,
|
||||
/// Whether the scanline probe had a PHYSICAL head to ask (exclusive topology leaves only our
|
||||
/// IDD active — latency still counts, scanline values don't).
|
||||
pub(super) scanline_physical: bool,
|
||||
/// Worst high-res sleeper overshoot (µs) — the DPC-storm / CPU-starvation discriminator.
|
||||
pub(super) cpu_max_overshoot_us: Option<u64>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProbeWindow {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let ms = |v: Option<u64>| match v {
|
||||
Some(us) => format!("{:.0}ms", us as f64 / 1_000.0),
|
||||
None => "absent".to_string(),
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"fence={} dwm_tick_frozen={} dwm_flush={} scanline={}({}) cpu_overshoot={}",
|
||||
ms(self.fence_max_us),
|
||||
ms(self.dwm_tick_frozen_us),
|
||||
ms(self.dwm_flush_max_us),
|
||||
ms(self.scanline_max_us),
|
||||
if self.scanline_physical {
|
||||
"physical"
|
||||
} else {
|
||||
"virtual"
|
||||
},
|
||||
ms(self.cpu_max_overshoot_us),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The named disturbance class a stall's combined evidence supports — the [`attribute`] verdict
|
||||
/// (driver telemetry, Phase A.1) refined by the micro-probe window (Phase A.2). This is the
|
||||
/// per-stall output of the program's verdict matrix (design doc §4.4).
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub(super) enum StallClass {
|
||||
/// The drain worker starved — ours (CPU/MMCSS/dead WUDFHost).
|
||||
OursWorker,
|
||||
/// Frames were composed and offered but never became consumable — ours (ring/publish/consume).
|
||||
OursDelivery,
|
||||
/// Engine-liveness fences stalled with the hole: the ADAPTER froze (Level-Two/Three DDI
|
||||
/// servicing — link train, power transition, mux). Class 1.
|
||||
AdapterFreeze,
|
||||
/// Engines alive but DWM's own tick froze: the compositor is blocked on something (DDC/child
|
||||
/// I/O vendor lock, win32k display-config queue). Class 2.
|
||||
CompositorBlocked,
|
||||
/// Engines alive, DWM ticking, driver drained E_PENDING — composition happened for OTHER
|
||||
/// surfaces but produced no frame for OUR display: the frame-generation path
|
||||
/// (IddCx/dirty-tracking/divider). Ours to chase with IddCx WPP.
|
||||
FrameGeneration,
|
||||
/// Not enough evidence to name a class (pre-telemetry driver and/or probes absent).
|
||||
Unattributed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StallClass {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::OursWorker => "OURS-worker (drain thread starved)",
|
||||
Self::OursDelivery => "OURS-delivery (ring/publish/consume lost composed frames)",
|
||||
Self::AdapterFreeze => {
|
||||
"CLASS-1 adapter freeze (engines stalled below the OS — link/power/mux servicing)"
|
||||
}
|
||||
Self::CompositorBlocked => {
|
||||
"CLASS-2 compositor blocked (engines alive, DWM tick frozen — vendor lock / DDC)"
|
||||
}
|
||||
Self::FrameGeneration => {
|
||||
"FRAME-GENERATION (DWM ticked, engines alive, no frame for THIS display — IddCx/dirty/divider)"
|
||||
}
|
||||
Self::Unattributed => "UNATTRIBUTED (insufficient telemetry)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The verdict matrix: fold the driver-telemetry verdict and the probe window into a class.
|
||||
/// Pure — unit-tested beside the [`StallWatch`] tests. A leg is "stalled for the hole" when its
|
||||
/// worst reading covers at least half the gap (the same proportional bar as [`attribute`]).
|
||||
pub(super) fn classify(
|
||||
gap: Duration,
|
||||
verdict: &StallVerdict,
|
||||
probes: Option<&ProbeWindow>,
|
||||
) -> StallClass {
|
||||
match verdict {
|
||||
StallVerdict::WorkerStalled => return StallClass::OursWorker,
|
||||
StallVerdict::DeliveryLeg => return StallClass::OursDelivery,
|
||||
StallVerdict::ComposeSilence | StallVerdict::NoTelemetry => {}
|
||||
}
|
||||
let Some(p) = probes else {
|
||||
return StallClass::Unattributed;
|
||||
};
|
||||
let half_gap_us = (gap.as_micros() as u64) / 2;
|
||||
let covers = |v: Option<u64>| v.is_some_and(|us| us >= half_gap_us);
|
||||
if covers(p.fence_max_us) {
|
||||
return StallClass::AdapterFreeze;
|
||||
}
|
||||
if covers(p.dwm_tick_frozen_us) || covers(p.dwm_flush_max_us) {
|
||||
return StallClass::CompositorBlocked;
|
||||
}
|
||||
// Engines alive and DWM ticking: only the driver's own E_PENDING testimony can pin the
|
||||
// frame-generation path — without it (pre-telemetry driver) the delivery leg is equally
|
||||
// possible, so stay honest.
|
||||
if matches!(verdict, StallVerdict::ComposeSilence) {
|
||||
StallClass::FrameGeneration
|
||||
} else {
|
||||
StallClass::Unattributed
|
||||
}
|
||||
}
|
||||
|
||||
/// The attribution a stall's evidence supports — the Branch-1/Branch-2 fork of the
|
||||
/// vdisplay-disturbance-immunity program, computed per stall instead of argued per field report.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum StallVerdict {
|
||||
/// Pre-telemetry driver: no verdict, the log says only what the host observed.
|
||||
NoTelemetry,
|
||||
/// The drain worker's heartbeat went silent for a large share of the hole — the swap-chain
|
||||
/// thread starved (CPU/MMCSS) or the WUDFHost died. Ours, host/driver side.
|
||||
WorkerStalled,
|
||||
/// The worker drained E_PENDING throughout: DWM composed NOTHING for the hole. The
|
||||
/// disturbance is below capture (adapter servicing / DDC lock / present clock) — the
|
||||
/// micro-probe + ETW phases discriminate further.
|
||||
ComposeSilence,
|
||||
/// DWM composed frames all through the hole and the driver offered them, but none became a
|
||||
/// consumable ring slot — OUR publish/ring/consume leg lost them. Fully killable.
|
||||
DeliveryLeg,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StallVerdict {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::NoTelemetry => "pre-telemetry driver (no verdict)",
|
||||
Self::WorkerStalled => "driver-worker-stalled (heartbeat silent) — host CPU/MMCSS or a dead WUDFHost, NOT the display path",
|
||||
Self::ComposeSilence => "compose-silence (driver drained E_PENDING) — DWM composed nothing; the disturbance is below capture",
|
||||
Self::DeliveryLeg => "delivery-leg (frames were composed + offered but never consumable) — OUR ring/publish/consume leg",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn one stall window's evidence into a [`StallVerdict`]. Pure — unit-tested beside the
|
||||
/// [`StallWatch`] tests.
|
||||
///
|
||||
/// Thresholds: a heartbeat that was ever `max(gap/2, 250 ms)` stale convicts the worker (its
|
||||
/// scheduled cadence is ≤16 ms, so 250 ms of silence is real starvation, and gap/2 scales the bar
|
||||
/// for long holes); `offered_delta ≥ 8` acquits DWM (8 composed frames during the "hole" mirrors
|
||||
/// [`StallWatch::RECENT`]'s definition of sustained flow — the stall-ending frame plus a resume
|
||||
/// burst stay well under it).
|
||||
pub(super) fn attribute(gap: Duration, evidence: &StallEvidence) -> StallVerdict {
|
||||
let Some(offered) = evidence.offered_delta else {
|
||||
return StallVerdict::NoTelemetry;
|
||||
};
|
||||
let gap_ms = gap.as_millis() as u64;
|
||||
if evidence.max_heartbeat_age_ms >= (gap_ms / 2).max(250) {
|
||||
StallVerdict::WorkerStalled
|
||||
} else if offered >= 8 {
|
||||
StallVerdict::DeliveryLeg
|
||||
} else {
|
||||
StallVerdict::ComposeSilence
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
|
||||
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
|
||||
/// path BELOW capture and only while no physical output is active).
|
||||
@@ -35,6 +223,13 @@ pub(super) struct StallWatch {
|
||||
/// [`Self::report`] uses. They were capturer fields that nothing outside the report touched.
|
||||
seen: u32,
|
||||
with_os_events: u32,
|
||||
/// Running per-verdict tally (worker-stalled / compose-silence / delivery-leg / no-telemetry),
|
||||
/// in [`StallVerdict`] order — the metronomic WARN prints it, so one pasted line attributes the
|
||||
/// whole session's beat, not just the stall that tripped the metronome.
|
||||
verdicts: [u32; 4],
|
||||
/// Running per-class tally ([`StallClass`] order: ours-worker, ours-delivery, adapter-freeze,
|
||||
/// compositor-blocked, frame-generation, unattributed) — the verdict matrix's session summary.
|
||||
classes: [u32; 6],
|
||||
}
|
||||
|
||||
impl StallWatch {
|
||||
@@ -54,6 +249,8 @@ impl StallWatch {
|
||||
cadence: pf_frame::metronome::Metronome::new(),
|
||||
seen: 0,
|
||||
with_os_events: 0,
|
||||
verdicts: [0; 4],
|
||||
classes: [0; 6],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +289,10 @@ impl StallWatch {
|
||||
/// a running tally, all of it about stalls and none of it about consuming a frame, in a function
|
||||
/// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one
|
||||
/// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window.
|
||||
pub(super) fn report(&mut self, stall: &Stall, now: Instant) {
|
||||
/// `evidence` is the capturer's driver-telemetry sample for the window; its [`attribute`]
|
||||
/// verdict rides every stall line, so a field log names which leg lost the frames instead of
|
||||
/// leaving it to hypothesis.
|
||||
pub(super) fn report(&mut self, stall: &Stall, now: Instant, evidence: &StallEvidence) {
|
||||
// OS display events inside the gap (plus a lead-in margin: the event that CAUSED the
|
||||
// hole lands just before DWM stops delivering) — the attribution that turns "DWM
|
||||
// stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI".
|
||||
@@ -105,14 +305,36 @@ impl StallWatch {
|
||||
if !events.is_empty() {
|
||||
self.with_os_events = self.with_os_events.saturating_add(1);
|
||||
}
|
||||
let verdict = attribute(stall.gap, evidence);
|
||||
self.verdicts[match verdict {
|
||||
StallVerdict::NoTelemetry => 0,
|
||||
StallVerdict::WorkerStalled => 1,
|
||||
StallVerdict::ComposeSilence => 2,
|
||||
StallVerdict::DeliveryLeg => 3,
|
||||
}] += 1;
|
||||
let class = classify(stall.gap, &verdict, evidence.probes.as_ref());
|
||||
self.classes[match class {
|
||||
StallClass::OursWorker => 0,
|
||||
StallClass::OursDelivery => 1,
|
||||
StallClass::AdapterFreeze => 2,
|
||||
StallClass::CompositorBlocked => 3,
|
||||
StallClass::FrameGeneration => 4,
|
||||
StallClass::Unattributed => 5,
|
||||
}] += 1;
|
||||
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||
// at debug level, and the web-console debug ring captures these.
|
||||
tracing::debug!(
|
||||
gap_ms = stall.gap.as_millis() as u64,
|
||||
os_display_events = %pf_win_display::display_events::summarize(&events),
|
||||
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||
delivered no frame for the gap; the present path stalled below capture"
|
||||
verdict = %verdict,
|
||||
class = %class,
|
||||
probes = evidence.probes.as_ref().map(tracing::field::display),
|
||||
etw = evidence.etw.as_deref().unwrap_or("unavailable"),
|
||||
offered_during_gap = evidence.offered_delta,
|
||||
max_heartbeat_age_ms = evidence.max_heartbeat_age_ms,
|
||||
"IDD-push capture stall — the desktop was composing at speed, then the ring \
|
||||
delivered no frame for the gap; the class names the leg that lost them"
|
||||
);
|
||||
if let Some(period) = stall.metronomic {
|
||||
let suspects = pf_win_display::display_events::connected_inactive_physicals();
|
||||
@@ -122,6 +344,21 @@ impl StallWatch {
|
||||
suspects.join(", ")
|
||||
};
|
||||
let correlated = format!("{}/{}", self.with_os_events, self.seen);
|
||||
// The session's attribution in one token: which leg the evidence convicted, per stall.
|
||||
let verdict_tally = format!(
|
||||
"worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}",
|
||||
self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0]
|
||||
);
|
||||
let class_tally = format!(
|
||||
"ours-worker {}, ours-delivery {}, adapter-freeze {}, compositor-blocked {}, \
|
||||
frame-generation {}, unattributed {}",
|
||||
self.classes[0],
|
||||
self.classes[1],
|
||||
self.classes[2],
|
||||
self.classes[3],
|
||||
self.classes[4],
|
||||
self.classes[5]
|
||||
);
|
||||
// Half-or-more of the stalls carrying a coinciding OS event = the reaction
|
||||
// cascade is OS-visible; otherwise the disturbance never surfaces above the
|
||||
// driver. Different classes, different cures — say which one this box has.
|
||||
@@ -130,6 +367,8 @@ impl StallWatch {
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
verdicts = %verdict_tally,
|
||||
classes = %class_tally,
|
||||
"capture stalls are METRONOMIC and coincide with Windows monitor \
|
||||
hot-plug/re-enumeration events — a connected display (or its \
|
||||
cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \
|
||||
@@ -145,6 +384,8 @@ impl StallWatch {
|
||||
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||
os_correlated = correlated,
|
||||
connected_inactive = %suspects,
|
||||
verdicts = %verdict_tally,
|
||||
classes = %class_tally,
|
||||
"capture stalls are METRONOMIC with NO coinciding OS display event — \
|
||||
the disturbance is BELOW Windows: the GPU driver servicing a \
|
||||
connected-but-asleep sink (standby HPD/DDC/link probing), \
|
||||
|
||||
@@ -490,8 +490,17 @@ pub mod frame {
|
||||
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
|
||||
/// only attaches to a fully-published ring.
|
||||
pub const MAGIC: u32 = 0x4456_4650;
|
||||
/// Frame-plane version (independent bump of the header layout).
|
||||
pub const VERSION: u32 = 1;
|
||||
/// Frame-plane version (independent bump of the header layout). v2 appended the stall-attribution
|
||||
/// telemetry tail (`drain_heartbeat_qpc`/`last_acquire_qpc`/`offered_total`); see
|
||||
/// [`VERSION_TELEMETRY`] for the compatibility contract.
|
||||
pub const VERSION: u32 = 2;
|
||||
/// The header version that grew the telemetry tail. Compatibility is gated on the HOST-stamped
|
||||
/// `version` field, not on mapping sizes: a v2 driver writes the tail only when
|
||||
/// `version >= VERSION_TELEMETRY` (a v1 host created a 64-byte layout — never write past it),
|
||||
/// and a v2 host reads `drain_heartbeat_qpc == 0` as "pre-telemetry driver, no verdict" (the
|
||||
/// same zero-means-absent convention as [`OPENED_DETAIL_LIVE`]). Neither side rejects the
|
||||
/// other's version — the tail is diagnostics, not frame-plane semantics.
|
||||
pub const VERSION_TELEMETRY: u32 = 2;
|
||||
/// Ring slots. Headroom so the driver's 0 ms-timeout publish always finds a free slot while the host
|
||||
/// holds one across the convert/copy + the pipelined encode. MUST be identical on both sides — it is,
|
||||
/// because both read this one constant.
|
||||
@@ -587,6 +596,22 @@ pub mod frame {
|
||||
/// token blocks file writes, so this header is how the driver reports state).
|
||||
pub driver_status: u32,
|
||||
pub driver_status_detail: u32,
|
||||
/// v2 telemetry tail (stall attribution, Phase A.1 of the vdisplay-disturbance-immunity
|
||||
/// program): driver-written on every drain-loop pass, host-read when a capture stall ends.
|
||||
/// All three are best-effort Relaxed atomic stores (the `driver_status` visibility
|
||||
/// contract); `0` means a pre-v2 driver never wrote them. QPC of the swap-chain worker's
|
||||
/// most recent drain-loop iteration — E_PENDING passes included, so a fresh heartbeat over
|
||||
/// a stale [`Self::last_acquire_qpc`] reads "the worker is running and DWM is composing
|
||||
/// nothing", while a stale heartbeat reads "our worker starved".
|
||||
pub drain_heartbeat_qpc: u64,
|
||||
/// QPC of the most recent SUCCESSFUL swap-chain acquire — the last instant DWM actually
|
||||
/// composed this display. The Branch-1/Branch-2 fork in one field.
|
||||
pub last_acquire_qpc: u64,
|
||||
/// Wrapping count of surfaces offered to the ring publisher — the full-width sibling of
|
||||
/// the packed 15-bit [`OPENED_DETAIL_LIVE`] counter (which saturates and cannot be
|
||||
/// delta'd over a stall window). The host snapshots it per consumed frame; the delta
|
||||
/// across a stall says whether frames existed that never reached the ring.
|
||||
pub offered_total: u64,
|
||||
}
|
||||
|
||||
/// Why the driver's publisher must NOT attach a delivered channel to its monitor's ring — the
|
||||
@@ -663,7 +688,7 @@ pub mod frame {
|
||||
const _: () = {
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<SharedHeader>() == 64);
|
||||
assert!(size_of::<SharedHeader>() == 88);
|
||||
assert!(offset_of!(SharedHeader, magic) == 0);
|
||||
assert!(offset_of!(SharedHeader, version) == 4);
|
||||
assert!(offset_of!(SharedHeader, generation) == 8);
|
||||
@@ -678,6 +703,9 @@ pub mod frame {
|
||||
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
|
||||
assert!(offset_of!(SharedHeader, driver_status) == 56);
|
||||
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
|
||||
assert!(offset_of!(SharedHeader, drain_heartbeat_qpc) == 64);
|
||||
assert!(offset_of!(SharedHeader, last_acquire_qpc) == 72);
|
||||
assert!(offset_of!(SharedHeader, offered_total) == 80);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1426,14 +1454,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_header_is_pod_and_64_bytes() {
|
||||
fn shared_header_is_pod_and_88_bytes() {
|
||||
let mut h = frame::SharedHeader::zeroed();
|
||||
h.magic = frame::MAGIC;
|
||||
h.width = 5120;
|
||||
h.height = 1440;
|
||||
h.target_id = 262;
|
||||
h.drain_heartbeat_qpc = 0x1234_5678_9abc_def0;
|
||||
let bytes = bytemuck::bytes_of(&h);
|
||||
assert_eq!(bytes.len(), 64);
|
||||
assert_eq!(bytes.len(), 88);
|
||||
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
|
||||
assert_eq!(back.magic, frame::MAGIC);
|
||||
assert_eq!(back.width, 5120);
|
||||
@@ -1441,6 +1470,11 @@ mod tests {
|
||||
// v3: the monitor binding occupies the old `_pad` slot at offset 28 — byte-compatible (a v2
|
||||
// host left it zero there).
|
||||
assert_eq!(bytes[28..32], 262u32.to_le_bytes());
|
||||
// Header v2: the telemetry tail is appended — the first 64 bytes ARE the v1 layout, so a
|
||||
// pre-telemetry driver reading its 64-byte view sees exactly what it always saw.
|
||||
assert_eq!(bytes[64..72], 0x1234_5678_9abc_def0u64.to_le_bytes());
|
||||
assert_eq!(back.last_acquire_qpc, 0);
|
||||
assert_eq!(back.offered_total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -385,9 +385,15 @@ impl WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: 0,
|
||||
instance_prefix: "pf_pad",
|
||||
hwid: "pf_gamepad",
|
||||
// ⚠️ A HARDWARE ID, not the package name. `pf-dualsense` became `pf-gamepad` in
|
||||
// 560e663a and this line was renamed with it — but the INF deliberately kept the four
|
||||
// OLD hardware ids, so `pf_gamepad` matched no INF of ours. PnP then fell through to
|
||||
// the devnode's synthesized USB ids, bound Microsoft's inbox `input.inf`/`HidUsb`, and
|
||||
// that cannot start on a software-enumerated devnode: CM_PROB_FAILED_START, no HID
|
||||
// child, no channel proof, and a pad no game ever saw. `hwid_matches_inf` pins it now.
|
||||
hwid: "pf_dualsense",
|
||||
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||
description: "punktfunk Virtual DualSense",
|
||||
description: "Punktfunk Virtual DualSense",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +403,7 @@ impl WinDsIdentity {
|
||||
instance_prefix: "pf_edge",
|
||||
hwid: "pf_dualsenseedge",
|
||||
usb_vid_pid: "VID_054C&PID_0DF2",
|
||||
description: "punktfunk Virtual DualSense Edge",
|
||||
description: "Punktfunk Virtual DualSense Edge",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -633,12 +639,12 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
hwid: super::steam_deck_windows::DECK_HWID,
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The Deck's controller interface — the promotion gate the first spike run hit
|
||||
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck (spike)",
|
||||
description: "Punktfunk Virtual Steam Deck (spike)",
|
||||
})?;
|
||||
// The spike drives a real pad channel, so it takes the same devnode-proved delivery a session
|
||||
// pad does — no special case, and no reason for a bring-up tool to run on the old trust.
|
||||
@@ -802,6 +808,58 @@ mod drain_tests {
|
||||
assert_eq!(got, vec![vec![0x02, 99]]);
|
||||
}
|
||||
|
||||
/// Every hardware id the host puts on a pad devnode must be one the shipped INF actually
|
||||
/// declares — otherwise PnP matches none of our models, falls through to the synthesized USB
|
||||
/// ids on the same devnode, and binds Microsoft's inbox `input.inf`/`HidUsb`, which cannot
|
||||
/// start on a software-enumerated devnode. The result is a pad that exists, never starts, and
|
||||
/// never answers a channel proof; that is exactly what shipped in 0.22.0/0.22.1 for the plain
|
||||
/// DualSense, because the `pf-dualsense` -> `pf-gamepad` PACKAGE rename also rewrote this
|
||||
/// HARDWARE id (560e663a). The ids are a binding contract with every installed system, so they
|
||||
/// must outlive any future package rename — this test is what makes that structural.
|
||||
#[test]
|
||||
fn hwid_matches_inf() {
|
||||
let inx = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx"
|
||||
);
|
||||
let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx");
|
||||
// The [Models] lines: `%DeviceDesc…%=pfGamepad, <hwid>[, <hwid>…]`.
|
||||
let declared: Vec<String> = inf
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.starts_with(';'))
|
||||
.filter_map(|l| l.split_once("=pfGamepad,"))
|
||||
.flat_map(|(_, ids)| {
|
||||
ids.split(',')
|
||||
.map(|id| id.trim().to_ascii_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
declared.len() >= 4,
|
||||
"parsed {} hardware ids out of {inx} — the [Models] shape changed and this test went \
|
||||
vacuous; fix the parse rather than deleting the assert",
|
||||
declared.len()
|
||||
);
|
||||
for hwid in [
|
||||
WinDsIdentity::dualsense().hwid,
|
||||
WinDsIdentity::dualsense_edge().hwid,
|
||||
super::super::dualshock4_windows::DS4_HWID,
|
||||
super::super::steam_deck_windows::DECK_HWID,
|
||||
] {
|
||||
let want = hwid.to_ascii_lowercase();
|
||||
let rooted = format!("root\\{want}");
|
||||
assert!(
|
||||
declared
|
||||
.iter()
|
||||
.any(|d| d.as_str() == want || d.as_str() == rooted),
|
||||
"the host creates pad devnodes with hardware id {hwid:?}, which pf_gamepad.inx \
|
||||
does not declare (it has {declared:?}) — PnP would bind inbox input.inf/HidUsb \
|
||||
instead and the pad would never start"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_driver_still_drains_the_latest_slot() {
|
||||
let mut buf = section();
|
||||
|
||||
@@ -21,6 +21,10 @@ use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package
|
||||
/// rename must never touch it (`dualsense_windows::tests::hwid_matches_inf` enforces that).
|
||||
pub(super) const DS4_HWID: &str = "pf_dualshock4";
|
||||
|
||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
|
||||
@@ -66,10 +70,10 @@ impl Ds4WinPad {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_dualshock4",
|
||||
hwid: DS4_HWID,
|
||||
usb_vid_pid: "VID_054C&PID_09CC",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual DualShock 4",
|
||||
description: "Punktfunk Virtual DualShock 4",
|
||||
})?; // Propagate, do NOT swallow — see below.
|
||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
||||
// Swallowing a create failure here (the previous behaviour) latched the pad slot to
|
||||
|
||||
@@ -56,7 +56,7 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let desc: Vec<u16> = "punktfunk Virtual Xbox 360 (XUSB)"
|
||||
let desc: Vec<u16> = "Punktfunk Virtual Xbox 360 (XUSB)"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
@@ -66,7 +66,7 @@ impl VirtualMouse {
|
||||
// a mouse (nothing fingerprints them); reusing the shared profile keeps one code path.
|
||||
usb_vid_pid: "VID_5046&PID_4D4F",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual Mouse",
|
||||
description: "Punktfunk Virtual Mouse",
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
@@ -386,7 +386,7 @@ pub fn channel_proof_probe() -> Result<()> {
|
||||
hwid: "pf_mouse",
|
||||
usb_vid_pid: "VID_5046&PID_4D4F",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual Mouse (channel-proof probe)",
|
||||
description: "Punktfunk Virtual Mouse (channel-proof probe)",
|
||||
})?;
|
||||
let _sw = super::gamepad_raii::SwDevice::new(hsw);
|
||||
let Some(instance_id) = instance_id else {
|
||||
|
||||
@@ -30,6 +30,10 @@ use anyhow::Result;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package
|
||||
/// rename must never touch it (`dualsense_windows::tests::hwid_matches_inf` enforces that).
|
||||
pub(super) const DECK_HWID: &str = "pf_steamdeck";
|
||||
|
||||
/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_<index>` devnode plus the sealed
|
||||
/// shared-memory channel. Dropping it removes the devnode and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait).
|
||||
@@ -70,13 +74,13 @@ impl DeckWinPad {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
container_index: index,
|
||||
hwid: "pf_steamdeck",
|
||||
hwid: DECK_HWID,
|
||||
usb_vid_pid: "VID_28DE&PID_1205",
|
||||
// The wired Deck controller interface — WITHOUT this the HID child carries no MI_
|
||||
// token, hidapi reports interface 0, and Steam never claims the pad (the N4
|
||||
// spike's run-1 failure).
|
||||
usb_mi: Some(2),
|
||||
description: "punktfunk Virtual Steam Deck",
|
||||
description: "Punktfunk Virtual Steam Deck",
|
||||
})?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin).
|
||||
let (hsw, instance_id) = (Some(hsw), instance_id);
|
||||
// The DATA section goes to whoever THIS devnode says is serving it — not to whatever pid
|
||||
|
||||
@@ -50,13 +50,19 @@ const APPLY_TEMPORARY: u32 = 1;
|
||||
/// the frames. The capturer always negotiates the meta (pf-capture `meta_param`) and the encoder
|
||||
/// blend composites it for sessions where the client does not draw the cursor itself — while a
|
||||
/// cursor-forwarding session strips the overlay and sends shape/state over the cursor channel.
|
||||
/// Embedded mode would leave BOTH paths blind: no metadata means nothing to forward AND nothing
|
||||
/// to blend, and Mutter's own embedded painting is what the pre-channel path relied on.
|
||||
/// This is the mode for EVERY session whose host plans a composite or forward (`set_hw_cursor`
|
||||
/// on), channel or not: Mutter's embedded painting is a fiction on a virtual stream — since
|
||||
/// Mutter 48 (commit `7ff5334a`, hw-cursor inhibition removed) the software cursor overlay is
|
||||
/// suppressed STAGE-GLOBALLY whenever any physical head realizes a hardware cursor, so
|
||||
/// dmabuf-recorded frames blit the view without a pointer, and cursor-only motion schedules no
|
||||
/// re-record either (mutter#4939). Probed on-glass (Mutter 50.3): embedded + relative motion =
|
||||
/// frozen frame counter; metadata positions kept flowing in the same setup.
|
||||
const CURSOR_METADATA: u32 = 2;
|
||||
/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself —
|
||||
/// zero host-side cursor work, the pre-channel path. Chosen for every session WITHOUT the
|
||||
/// negotiated cursor channel (`set_hw_cursor` off — Phase B, the Windows no-regression gate
|
||||
/// mirrored); metadata stays the cursor-channel sessions' mode (shapes forwarded / host blend).
|
||||
/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself.
|
||||
/// Kept only as the can't-blend fallback (`set_hw_cursor` off — the resolved encode backend
|
||||
/// cannot composite a metadata cursor, so metadata would strand the pointer in meta nothing
|
||||
/// draws). Know its limits (above): on a virtual stream it paints only into the MemFd/SHM
|
||||
/// record path (`FORCE_CURSORS`) and only refreshes on unrelated damage.
|
||||
const CURSOR_EMBEDDED: u32 = 1;
|
||||
|
||||
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
|
||||
|
||||
@@ -92,7 +92,7 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
|
||||
}
|
||||
|
||||
/// Reap the ghost (NOT-present) "punktfunk" virtual-monitor device nodes that `IddCxMonitorDeparture`
|
||||
/// leaves behind. Each departed monitor leaves a not-present "Generic Monitor (punktfunk)" PDO that keeps
|
||||
/// leaves behind. Each departed monitor leaves a not-present "Generic Monitor (Punktfunk)" PDO that keeps
|
||||
/// pinning an OS VidPN target against the IddCx adapter's fixed monitor-slot budget; once ~16 accumulate,
|
||||
/// `IOCTL_ADD` wedges at 0x80070490 (`ERROR_NOT_FOUND`) and every session black-screens until a manual
|
||||
/// reset/reboot. Removing the not-present PDOs frees the slots — the in-process equivalent of
|
||||
@@ -532,7 +532,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
|
||||
}
|
||||
// CLEAR_ALL only departs the driver's own (in-process) monitor list; it can NOT remove the
|
||||
// OS-side not-present "Generic Monitor (punktfunk)" PDOs that a previous host-run's monitor
|
||||
// OS-side not-present "Generic Monitor (Punktfunk)" PDOs that a previous host-run's monitor
|
||||
// departures left behind. Reap those here so a fresh host start begins with a clean IddCx
|
||||
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
|
||||
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
|
||||
|
||||
@@ -1524,6 +1524,49 @@ pub fn source_desktop_rect(target_id: u32) -> Option<(i32, i32, i32, i32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Scanline-probe target (stall-attribution Phase A.2): the adapter LUID + VidPn SOURCE id of an
|
||||
/// ACTIVE path, preferring a real display over one of OUR virtual monitors. `D3DKMTGetScanLine`
|
||||
/// wants a source that actually scans out, so a physical head (`physical == true`) gives the
|
||||
/// honest Level-Zero KMD-liveness probe; on an exclusive topology only our IDD is active, and the
|
||||
/// caller still gets it (`physical == false`) — the CALL's latency is the measurement either way,
|
||||
/// the flag just keeps the report from over-reading the returned scanline values. Returns
|
||||
/// `(adapter_luid_low, adapter_luid_high, vidpn_source_id, physical)`; `None` when nothing is
|
||||
/// active or the query fails.
|
||||
pub fn active_scanline_target() -> Option<(u32, i32, u32, bool)> {
|
||||
// SAFETY: `query_active_config` is this module's own CCD helper: it takes nothing and returns owned
|
||||
// `Vec`s built from a fresh `QueryDisplayConfig`, so it has no caller obligation at all.
|
||||
let (paths, _modes) = query_active_config()?;
|
||||
let mut fallback: Option<(u32, i32, u32, bool)> = None;
|
||||
for p in &paths {
|
||||
if p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 {
|
||||
continue;
|
||||
}
|
||||
let candidate = (
|
||||
p.sourceInfo.adapterId.LowPart,
|
||||
p.sourceInfo.adapterId.HighPart,
|
||||
p.sourceInfo.id,
|
||||
false,
|
||||
);
|
||||
let mut req = DISPLAYCONFIG_TARGET_DEVICE_NAME::default();
|
||||
req.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
|
||||
req.header.size = size_of::<DISPLAYCONFIG_TARGET_DEVICE_NAME>() as u32;
|
||||
req.header.adapterId = p.targetInfo.adapterId;
|
||||
req.header.id = p.targetInfo.id;
|
||||
// SAFETY: `req.header` is a live local whose `size` field was just set to the enclosing
|
||||
// struct's own `size_of` (the byte-count contract); the struct outlives this synchronous call.
|
||||
if unsafe { DisplayConfigGetDeviceInfo(&mut req.header) } != 0 {
|
||||
fallback.get_or_insert(candidate);
|
||||
continue;
|
||||
}
|
||||
if is_our_virtual_display(&utf16z_str(&req.monitorDevicePath)) {
|
||||
fallback.get_or_insert(candidate);
|
||||
continue;
|
||||
}
|
||||
return Some((candidate.0, candidate.1, candidate.2, true));
|
||||
}
|
||||
fallback
|
||||
}
|
||||
|
||||
/// The union of every ACTIVE path's source rect — the virtual-desktop bounds `(x, y, w, h)` in
|
||||
/// desktop coordinates. Read from CCD rather than `GetSystemMetrics(SM_*VIRTUALSCREEN)` so the
|
||||
/// answer is the CONSOLE's real layout even when the calling process sits in another session (the
|
||||
|
||||
@@ -103,7 +103,18 @@ impl DataPump {
|
||||
// becomes the climb ceiling and slow start does the rest. Old hosts decline (all-zero
|
||||
// reply) or never answer (timeout clears the state so LossReports resume) — either way
|
||||
// the ceiling stays negotiated, exactly the old behavior. PUNKTFUNK_ABR_PROBE=0 opts out.
|
||||
const CAPACITY_PROBE_KBPS: u32 = 2_000_000;
|
||||
// `PUNKTFUNK_ABR_PROBE_KBPS` lowers the burst target (unset/0/garbage → the 2 Gbps
|
||||
// default): the target is deliberately far above any plausible link so the burst measures
|
||||
// the link and not itself, but on links the burst DISTURBS that backfires — a constrained
|
||||
// Wi-Fi link can black-hole under 2 Gbps (measured on webOS: the probe hitting the 6 s
|
||||
// timeout delayed first video to 14 s, and a "successful" one still reported
|
||||
// send_dropped=20211), and a 2-3 core TV client starves decoding the firehose. An
|
||||
// embedder that caps its own speed test wants this capped to match.
|
||||
let capacity_probe_kbps: u32 = std::env::var("PUNKTFUNK_ABR_PROBE_KBPS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||
.filter(|&v| v > 0)
|
||||
.unwrap_or(2_000_000);
|
||||
const CAPACITY_PROBE_MS: u32 = 800;
|
||||
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
|
||||
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
|
||||
@@ -245,14 +256,14 @@ impl DataPump {
|
||||
};
|
||||
if ctrl_tx
|
||||
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||
target_kbps: CAPACITY_PROBE_KBPS,
|
||||
target_kbps: capacity_probe_kbps,
|
||||
duration_ms: CAPACITY_PROBE_MS,
|
||||
}))
|
||||
.is_ok()
|
||||
{
|
||||
capacity_probe_deadline = Some(Instant::now() + CAPACITY_PROBE_TIMEOUT);
|
||||
tracing::info!(
|
||||
target_kbps = CAPACITY_PROBE_KBPS,
|
||||
target_kbps = capacity_probe_kbps,
|
||||
duration_ms = CAPACITY_PROBE_MS,
|
||||
"adaptive bitrate: startup link-capacity probe"
|
||||
);
|
||||
|
||||
@@ -202,6 +202,19 @@ pub enum EventKind {
|
||||
/// API (RFC §8) lands.
|
||||
source: String,
|
||||
},
|
||||
/// A verified update manifest announced a release newer than the running host. Emitted
|
||||
/// once per discovered version (a steady-state "newer exists" doesn't re-fire on every
|
||||
/// refresh).
|
||||
#[serde(rename = "update.available")]
|
||||
UpdateAvailable {
|
||||
/// The newer release's version string.
|
||||
version: String,
|
||||
/// The channel it was announced on (`stable` | `canary`).
|
||||
channel: String,
|
||||
/// This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the
|
||||
/// tray render the right "how to update" hint without a second call.
|
||||
install_kind: String,
|
||||
},
|
||||
#[serde(rename = "plugins.changed")]
|
||||
PluginsChanged {
|
||||
/// The plugin whose registration changed (registered, restarted, deregistered, or
|
||||
@@ -242,6 +255,7 @@ impl EventKind {
|
||||
EventKind::DisplayCreated { .. } => "display.created",
|
||||
EventKind::DisplayReleased { .. } => "display.released",
|
||||
EventKind::LibraryChanged { .. } => "library.changed",
|
||||
EventKind::UpdateAvailable { .. } => "update.available",
|
||||
EventKind::PluginsChanged { .. } => "plugins.changed",
|
||||
EventKind::StoreChanged => "store.changed",
|
||||
EventKind::HostStarted { .. } => "host.started",
|
||||
|
||||
@@ -389,12 +389,14 @@ fn run(
|
||||
return stream_body(
|
||||
&mut capturer,
|
||||
Some(&rebuild),
|
||||
// The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is
|
||||
// never called → the compositor EMBEDS the pointer where it can), so the encoder
|
||||
// is handed nothing to composite. gamescope remains the pointerless residual —
|
||||
// its capture carries no cursor either way (the native plane's XFixes source is
|
||||
// not wired on this plane).
|
||||
false,
|
||||
// Mirrors the source's own `set_hw_cursor` request (`open_gs_virtual_source`):
|
||||
// cursor-as-metadata + host blend wherever the backend composites — the
|
||||
// compositor-EMBEDS fallback never paints on a Mutter virtual stream (the
|
||||
// native plane's no-channel rule, `session_plan::cursor_blend_for`). gamescope
|
||||
// remains the pointerless residual — its capture carries no cursor either way
|
||||
// (the native plane's XFixes source is not wired on this plane).
|
||||
compositor != crate::vdisplay::Compositor::Gamescope
|
||||
&& blend_capable_metadata_cursor(&cfg),
|
||||
&sock,
|
||||
cfg,
|
||||
running,
|
||||
@@ -415,18 +417,7 @@ fn run(
|
||||
// `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask
|
||||
// the portal to EMBED the pointer so no backend × cursor-mode combination streams
|
||||
// cursorless. Synthetic frames carry no pointer either way.
|
||||
let metadata_cursor = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make:
|
||||
// the NVIDIA resolution plus the zero-copy master switch.
|
||||
let cuda_planned =
|
||||
!crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
false
|
||||
};
|
||||
let metadata_cursor = blend_capable_metadata_cursor(&cfg);
|
||||
// Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too:
|
||||
// the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the
|
||||
// virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host
|
||||
@@ -643,6 +634,24 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
|
||||
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
|
||||
/// FOLLOW a Desktop<->Game switch: it re-detects the now-live compositor and re-targets at it. Does NOT
|
||||
/// launch the app (that happens once at stream start; a rebuild must not re-spawn it).
|
||||
/// Cursor-as-metadata for this plane (GameStream has no cursor channel): only where the encode
|
||||
/// backend this session resolves to composites `frame.cursor` — the same CUDA-payload
|
||||
/// prediction `SessionPlan`/`handshake::cursor_forward` make (the NVIDIA resolution plus the
|
||||
/// zero-copy master switch). Shared by the monitor-mirror and virtual-output sources so their
|
||||
/// `set_hw_cursor` request and `stream_body`'s blend flag cannot drift.
|
||||
fn blend_capable_metadata_cursor(cfg: &StreamConfig) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||
crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = cfg;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn open_gs_virtual_source(
|
||||
cfg: StreamConfig,
|
||||
app: Option<&super::apps::AppEntry>,
|
||||
@@ -701,6 +710,16 @@ fn open_gs_virtual_source(
|
||||
}
|
||||
};
|
||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||
// Out-of-band cursor for the virtual source (the native plane's no-channel rule, mirrored):
|
||||
// GameStream has no cursor channel, and the compositor-EMBEDS fallback never paints on a
|
||||
// Mutter virtual stream (stage-global overlay suppression since Mutter 48 — see
|
||||
// pf-vdisplay `mutter.rs`), so ask for cursor-as-metadata wherever the resolved backend
|
||||
// composites `frame.cursor`; `stream_body`'s blend flag mirrors this request. gamescope
|
||||
// stays off: its capture carries no metadata either way, and the request would cost the
|
||||
// native-NV12 shape for nothing.
|
||||
vd.set_hw_cursor(
|
||||
compositor != crate::vdisplay::Compositor::Gamescope && blend_capable_metadata_cursor(&cfg),
|
||||
);
|
||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||
// process-global env var, so concurrent sessions can't stomp each other's launch target. It is
|
||||
// the RESOLVED command, so gamescope's bare spawn nests a library title exactly like an
|
||||
|
||||
@@ -99,6 +99,7 @@ mod stats_recorder;
|
||||
// same runner CLI the `plugins` subcommand uses (design/plugin-store.md).
|
||||
mod store;
|
||||
mod stream_marker;
|
||||
mod update;
|
||||
// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior
|
||||
// run; it lives in the `pf-win-display` leaf crate (plan §W6).
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -45,6 +45,7 @@ mod stats;
|
||||
mod store;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod update;
|
||||
|
||||
/// Default management port — adjacent to the GameStream block (47984…48010), and the same
|
||||
/// number Sunshine users already associate with "the config UI".
|
||||
@@ -257,7 +258,9 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
.routes(routes!(store::get_job))
|
||||
.routes(routes!(store::list_sources))
|
||||
.routes(routes!(store::put_source, store::delete_source))
|
||||
.routes(routes!(store::get_runtime, store::set_runtime)),
|
||||
.routes(routes!(store::get_runtime, store::set_runtime))
|
||||
.routes(routes!(update::get_update_status))
|
||||
.routes(routes!(update::force_update_check)),
|
||||
)
|
||||
.split_for_parts()
|
||||
}
|
||||
@@ -297,6 +300,7 @@ pub fn openapi_json() -> String {
|
||||
(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"),
|
||||
(name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"),
|
||||
(name = "update", description = "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
@@ -149,7 +149,12 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
|
||||
|| (method == Method::DELETE
|
||||
&& (path.starts_with("/api/v1/clients/")
|
||||
|| path.starts_with("/api/v1/native/clients/")))
|
||||
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"));
|
||||
|| (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential"))
|
||||
// The update surface is operator business end to end: today it is only a check, but
|
||||
// the same prefix will carry `apply` (running an installer / the root helper), and a
|
||||
// whole-prefix deny can't be defeated by a route added later.
|
||||
|| path == "/api/v1/update"
|
||||
|| path.starts_with("/api/v1/update/");
|
||||
!denied
|
||||
}
|
||||
|
||||
|
||||
@@ -544,6 +544,31 @@ fn plugin_allowlist_excludes_escalation_routes() {
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
|
||||
// The update surface, wholesale: today a check, tomorrow `apply` (an installer / the root
|
||||
// helper) — operator business end to end, denied by whole-prefix so the apply route added in
|
||||
// U1/U2 is denied by default rather than by remembering to list it. And it is deliberately
|
||||
// NOT on the paired-cert allowlist either: a streaming client has no business knowing or
|
||||
// steering the host's update state.
|
||||
for path in [
|
||||
"/api/v1/update",
|
||||
"/api/v1/update/status",
|
||||
"/api/v1/update/check",
|
||||
"/api/v1/update/apply-does-not-exist-yet",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::GET, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::POST, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
assert!(
|
||||
!auth::cert_may_access(&Method::GET, path),
|
||||
"a paired streaming cert must not reach {path}"
|
||||
);
|
||||
}
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::PUT,
|
||||
"/api/v1/store/sources/evil"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `/api/v1/update/*` — the host update-check surface (design
|
||||
//! `host-update-from-web-console.md` §4.2, phase U0).
|
||||
//!
|
||||
//! Admin lane ONLY: denied to the plugin token (`auth::plugin_may_access`) and absent from
|
||||
//! the paired-cert allowlist — an update trigger is operator business. U0 exposes `status` +
|
||||
//! `check`; the `apply` route arrives with the first apply leg (U1) so the API never
|
||||
//! advertises a capability no code backs.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::update::{self, detect};
|
||||
|
||||
/// One channel's manifest facts, as much as the console renders.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateManifestInfo {
|
||||
/// The released version this manifest announces.
|
||||
pub version: String,
|
||||
/// Publish serial (unix seconds) — monotonic per channel.
|
||||
pub serial: u64,
|
||||
/// RFC-3339 publish time (display only).
|
||||
pub published_at: String,
|
||||
/// Release-notes link (pinned to our forge by the manifest validator).
|
||||
pub notes_url: String,
|
||||
/// The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// The full update-check state for this host.
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct UpdateStatus {
|
||||
/// How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |
|
||||
/// `dnf` | `pacman` | `steamos-source` | `nix` | `source`.
|
||||
pub install_kind: String,
|
||||
/// Release channel this install follows: `stable` | `canary`.
|
||||
pub channel: String,
|
||||
/// The running host version.
|
||||
pub current_version: String,
|
||||
/// What the console may offer for this install: `notify` (show the command) — later
|
||||
/// phases add `full` (one-click apply) and `staged` (apply + reboot to finish).
|
||||
pub apply: String,
|
||||
/// The copy-pastable update command for this install kind.
|
||||
pub channel_hint: String,
|
||||
/// Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`).
|
||||
pub check_disabled: bool,
|
||||
/// A newer release than `current_version` exists for this channel (definitive
|
||||
/// comparisons only — an unparseable version pair never flags).
|
||||
pub available: bool,
|
||||
/// The last verified manifest, if any check has succeeded.
|
||||
pub manifest: Option<UpdateManifestInfo>,
|
||||
/// When the last successful check happened (unix seconds).
|
||||
pub last_checked_unix: Option<u64>,
|
||||
/// Why the last check failed, verbatim, if it did.
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
fn status_from(snap: update::Snapshot) -> UpdateStatus {
|
||||
let (kind, channel) = detect::detect();
|
||||
let current = env!("PUNKTFUNK_VERSION");
|
||||
let stale = snap.stale();
|
||||
let available = snap
|
||||
.checked
|
||||
.as_ref()
|
||||
.map(|c| detect::is_newer(&c.manifest.version, c.manifest.ci_run, current, channel))
|
||||
.unwrap_or(false);
|
||||
UpdateStatus {
|
||||
install_kind: kind.as_str().into(),
|
||||
channel: channel.as_str().into(),
|
||||
current_version: current.into(),
|
||||
apply: "notify".into(),
|
||||
channel_hint: detect::channel_hint(kind).into(),
|
||||
check_disabled: update::check_disabled(),
|
||||
available,
|
||||
manifest: snap.checked.as_ref().map(|c| UpdateManifestInfo {
|
||||
version: c.manifest.version.clone(),
|
||||
serial: c.manifest.serial,
|
||||
published_at: c.manifest.published_at.clone(),
|
||||
notes_url: c.manifest.notes_url.clone(),
|
||||
stale,
|
||||
}),
|
||||
last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix),
|
||||
last_error: snap.last_error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update-check status
|
||||
///
|
||||
/// How this host was installed, which channel it follows, whether a newer release is known,
|
||||
/// and how to update. Reading this may kick a background refresh when the cached check is
|
||||
/// older than 6 h; the response never blocks on the network.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/update/status",
|
||||
tag = "update",
|
||||
operation_id = "getUpdateStatus",
|
||||
responses(
|
||||
(status = OK, description = "Current update-check state", body = UpdateStatus),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn get_update_status() -> Json<UpdateStatus> {
|
||||
Json(status_from(update::snapshot_and_maybe_refresh()))
|
||||
}
|
||||
|
||||
/// Check for updates now
|
||||
///
|
||||
/// Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to
|
||||
/// one forced check per 30 s.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/update/check",
|
||||
tag = "update",
|
||||
operation_id = "forceUpdateCheck",
|
||||
responses(
|
||||
(status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus),
|
||||
(status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError),
|
||||
(status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn force_update_check() -> Response {
|
||||
match update::force_check().await {
|
||||
Ok(snap) => Json(status_from(snap)).into_response(),
|
||||
Err(update::ForceError::Disabled) => api_error(
|
||||
StatusCode::CONFLICT,
|
||||
"update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0)",
|
||||
),
|
||||
Err(update::ForceError::TooSoon) => api_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"a forced update check ran less than 30 s ago — try again shortly",
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,13 @@ use super::*;
|
||||
/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`,
|
||||
/// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that
|
||||
/// compositing stage — granting the channel over a backend that can't blend (libav
|
||||
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the
|
||||
/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never
|
||||
/// draws — never cursorless, never doubled. THE single predicate: the Welcome's
|
||||
/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back.
|
||||
/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied — or
|
||||
/// never asked for (a capture-latched client, `console.rs` `latched_mouse`) — the session
|
||||
/// composites host-side anyway wherever the backend can blend
|
||||
/// (`session_plan::cursor_blend_for`'s no-channel arm; the compositor-EMBEDS fallback never
|
||||
/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the
|
||||
/// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed
|
||||
/// from it, and the session wiring reads that bit back.
|
||||
pub(super) fn cursor_forward(
|
||||
client_caps: u8,
|
||||
compositor: Option<crate::vdisplay::Compositor>,
|
||||
|
||||
@@ -1076,20 +1076,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
ctx.bit_depth,
|
||||
ctx.chroma,
|
||||
ctx.codec,
|
||||
// Blend CAPABILITY for cursor-FORWARD sessions (Phase B, the Windows gate mirrored):
|
||||
// their client can flip to the capture mouse model mid-stream (`CursorRenderMode`), and
|
||||
// the composite side of that flip must not need an encoder rebuild — WHETHER a frame's
|
||||
// pointer is drawn stays per-tick (the encode loop strips `frame.cursor` while the client
|
||||
// draws locally, see the forwarder tick). Non-channel NON-gamescope sessions get the
|
||||
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
||||
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
||||
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
||||
// the blend must be built for every gamescope session. (`cursor_forward` is already
|
||||
// blend-gated: `handshake::cursor_forward` grants the channel only where
|
||||
// `encode::cursor_blend_capable` says the resolved backend composites.)
|
||||
// Blend CAPABILITY (the single rule in `cursor_blend_for`): cursor-FORWARD sessions
|
||||
// need it for the mid-stream capture-mouse flip (`CursorRenderMode` — WHETHER a
|
||||
// frame's pointer is drawn stays per-tick, the encode loop strips `frame.cursor`
|
||||
// while the client draws locally); gamescope (Phase C) can't embed a pointer, so the
|
||||
// host always composites the XFixes-sourced cursor; and a NO-channel session gets
|
||||
// metadata + host blend too wherever the backend composites — the compositor-EMBEDS
|
||||
// fallback streams cursorless on a Mutter virtual output (the overlay-visibility
|
||||
// gate is stage-global since Mutter 48; see `cursor_blend_for`'s doc). Embedded
|
||||
// remains only the can't-blend fallback.
|
||||
crate::session_plan::cursor_blend_for(
|
||||
ctx.cursor_forward,
|
||||
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
|
||||
ctx.codec,
|
||||
ctx.bit_depth,
|
||||
),
|
||||
ctx.cursor_forward,
|
||||
);
|
||||
@@ -1187,6 +1187,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
if gamescope_composite {
|
||||
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
||||
}
|
||||
// No-channel metadata composite: the client never draws the pointer (it did not advertise
|
||||
// the cursor channel — e.g. a capture-latched client, `console.rs` `latched_mouse`), and
|
||||
// the compositor-EMBEDS fallback is a fiction on a Mutter virtual stream (the software
|
||||
// cursor overlay is suppressed stage-globally whenever any physical head realizes a HW
|
||||
// cursor, Mutter 48+ — dmabuf frames blit the view WITHOUT it, and cursor-only motion
|
||||
// schedules no update either, mutter#4939). So the plan asked the backend for
|
||||
// cursor-as-metadata and the HOST composites, permanently — the same arm a channel
|
||||
// session lands in after its capture-model flip, minus the channel.
|
||||
// `mut`: recomputed with `gamescope_composite` on a mid-stream compositor retarget.
|
||||
let mut metadata_composite = cursor_fwd.is_none()
|
||||
&& plan.cursor_blend
|
||||
&& compositor != pf_vdisplay::Compositor::Gamescope;
|
||||
if metadata_composite {
|
||||
tracing::info!(
|
||||
"no cursor channel — compositing the metadata cursor into the video (embedded \
|
||||
fallback is unreliable on virtual streams)"
|
||||
);
|
||||
}
|
||||
if streamed_wire {
|
||||
// Client capability only — whether AUs actually stream per-slice depends on the encoder
|
||||
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
|
||||
@@ -1231,9 +1249,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// it with the HDR flags so nested games get HDR surfaces at all. Decided in the
|
||||
// Welcome (`capture::capturer_supports_hdr_for`), so it cannot change under us.
|
||||
vd.set_hdr(bit_depth >= 10);
|
||||
// Cursor-forward sessions ask the backend for an out-of-band hardware cursor
|
||||
// (Windows pf-vdisplay / IddCx; no-op on Linux — the portal already separates it).
|
||||
vd.set_hw_cursor(cursor_forward);
|
||||
// Out-of-band cursor request: cursor-forward sessions (Windows pf-vdisplay /
|
||||
// IddCx hardware cursor; Linux metadata mode) AND no-channel host-composite
|
||||
// sessions (Linux only — `metadata_composite` is `plan.cursor_blend`-gated, so
|
||||
// it is always false on Windows). The backend keeps the pointer out of the
|
||||
// pixels; the host blend (or the client) puts it back.
|
||||
vd.set_hw_cursor(cursor_forward || metadata_composite);
|
||||
// Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the
|
||||
// backend mints — the retry-hold below AND the capturer's — carries the session's quit
|
||||
// flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the
|
||||
@@ -2378,6 +2399,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan.cursor_blend = crate::session_plan::cursor_blend_for(
|
||||
plan.cursor_forward,
|
||||
c == crate::vdisplay::Compositor::Gamescope,
|
||||
plan.codec,
|
||||
plan.bit_depth,
|
||||
);
|
||||
plan.gamescope_cursor =
|
||||
crate::session_plan::gamescope_cursor_for(
|
||||
@@ -2385,6 +2408,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
);
|
||||
gamescope_composite =
|
||||
plan.gamescope_cursor && cursor_fwd.is_none();
|
||||
metadata_composite = cursor_fwd.is_none()
|
||||
&& plan.cursor_blend
|
||||
&& c != crate::vdisplay::Compositor::Gamescope;
|
||||
// The retargeted backend starts with `hw_cursor`
|
||||
// unset — without re-applying the session's
|
||||
// out-of-band cursor request, the rebuilt display
|
||||
// would come up EMBEDDED: double-drawn for a
|
||||
// desktop-model channel client, cursorless for
|
||||
// every host-composite session.
|
||||
vd.set_hw_cursor(plan.cursor_forward || metadata_composite);
|
||||
}
|
||||
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
|
||||
"capture loss: opening the newly-detected compositor failed — retrying"),
|
||||
@@ -2560,15 +2593,41 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if gamescope_composite {
|
||||
// gamescope (Phase C): no channel, host always composites. Refresh the (repeat or new)
|
||||
// frame's overlay from the capturer's LIVE cursor — the XFixes source publishes there
|
||||
// — so pointer-only motion on a static gamescope UI re-blends at tick rate instead of
|
||||
// freezing at the last damage frame (the same reason the composite arm above re-reads
|
||||
// it). A grabbed/hidden pointer arrives `visible: false` and is stripped just below.
|
||||
} else if gamescope_composite || metadata_composite {
|
||||
// No channel, host always composites: gamescope (Phase C — the XFixes source
|
||||
// publishes on `capturer.cursor()`) and the metadata-composite session (the portal
|
||||
// `SPA_META_Cursor` live overlay publishes there too). Refresh the (repeat or new)
|
||||
// frame's overlay from the capturer's LIVE cursor so pointer-only motion on a
|
||||
// static desktop re-blends at tick rate instead of freezing at the last damage
|
||||
// frame (the same reason the channel's composite arm above re-reads it). A
|
||||
// grabbed/hidden pointer arrives `visible: false` and is stripped just below.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Some(live) = capturer.cursor() {
|
||||
frame.cursor = Some(live);
|
||||
match capturer.cursor() {
|
||||
Some(live) => {
|
||||
if !composite_saw_overlay {
|
||||
composite_saw_overlay = true;
|
||||
tracing::info!(
|
||||
x = live.x,
|
||||
y = live.y,
|
||||
w = live.w,
|
||||
h = live.h,
|
||||
visible = live.visible,
|
||||
"host-composite: first live cursor overlay handed to the encoder \
|
||||
blend"
|
||||
);
|
||||
}
|
||||
frame.cursor = Some(live);
|
||||
}
|
||||
None => {
|
||||
if !composite_saw_none {
|
||||
composite_saw_none = true;
|
||||
tracing::info!(
|
||||
"host-composite active but the capture has no live cursor overlay \
|
||||
yet (no SPA_META_Cursor bitmap) — the stream is cursorless until \
|
||||
one arrives"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The overlay surfaces hidden pointers too (for the hint above) — strip them
|
||||
@@ -2579,10 +2638,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
|
||||
// the first frame of every (re)built display and by the capture-model flip). The first
|
||||
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
|
||||
// connection. Past those, only a cursor-channel session in the capture model that STILL
|
||||
// has no live overlay keeps trying: that combination means the pointer has not reached
|
||||
// the streamed output (the compositor reports cursor metadata only while it is over the
|
||||
// recorded view), and a relative-only client cannot get it there on its own.
|
||||
// connection. Past those, only a host-composite session that STILL has no live overlay
|
||||
// keeps trying — a channel session in the capture model, or a no-channel
|
||||
// metadata-composite session (both relative-only): no overlay there means the pointer
|
||||
// has not reached the streamed output (the compositor reports cursor metadata only
|
||||
// while it is over the recorded view), and a relative-only client cannot get it there
|
||||
// on its own.
|
||||
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
|
||||
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
|
||||
// parked regardless.
|
||||
@@ -2591,8 +2652,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
&& park_attempts < PARK_ATTEMPTS_MAX
|
||||
&& std::time::Instant::now() >= next_park_at
|
||||
{
|
||||
let composite_starved = cursor_fwd.is_some()
|
||||
&& !cursor_client_draws.load(Ordering::Relaxed)
|
||||
let composite_starved = ((cursor_fwd.is_some()
|
||||
&& !cursor_client_draws.load(Ordering::Relaxed))
|
||||
|| metadata_composite)
|
||||
&& capturer.cursor().is_none();
|
||||
if park_attempts < 2 || composite_starved {
|
||||
park_pointer(&input_tx, frame.width, frame.height);
|
||||
@@ -3406,13 +3468,14 @@ pub(super) fn prepare_display(
|
||||
bit_depth,
|
||||
chroma,
|
||||
codec,
|
||||
// Blend capability — must MATCH virtual_stream's resolve (Phase B: non-channel
|
||||
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
||||
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
||||
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
||||
// Blend capability — must MATCH virtual_stream's resolve. Windows-only path, where
|
||||
// the rule is a constant `false` (the IDD capturer composites itself); passed through
|
||||
// the shared rule anyway so the two resolves cannot drift.
|
||||
crate::session_plan::cursor_blend_for(
|
||||
cursor_forward,
|
||||
compositor == pf_vdisplay::Compositor::Gamescope,
|
||||
codec,
|
||||
bit_depth,
|
||||
),
|
||||
cursor_forward,
|
||||
);
|
||||
|
||||
@@ -108,7 +108,9 @@ pub struct SessionPlan {
|
||||
pub wire_chunk: Option<usize>,
|
||||
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
|
||||
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
|
||||
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
|
||||
/// where the ENCODER is the compositing stage (Linux: cursor-forward sessions, gamescope,
|
||||
/// AND no-channel sessions on a blend-capable backend — the compositor-EMBEDS fallback is
|
||||
/// broken on Mutter virtual streams, see [`cursor_blend_for`]);
|
||||
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
|
||||
/// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off
|
||||
/// those shapes when this is set — see [`Self::output_format`] and
|
||||
@@ -234,20 +236,46 @@ pub(crate) fn resolve_topology() -> SessionTopology {
|
||||
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
|
||||
/// the mid-stream compositor re-gate) so they can't drift:
|
||||
/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the
|
||||
/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture
|
||||
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
|
||||
/// capture-mouse flip needs the host composite on demand), for gamescope (its capture
|
||||
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video), AND
|
||||
/// for a no-channel session whenever the resolved backend can composite. The pre-channel
|
||||
/// "compositor EMBEDS the pointer" fallback is a fiction on a Mutter virtual stream:
|
||||
/// cursor-only motion never re-records the stream (probed on-glass, Mutter 50.3 — frames
|
||||
/// froze the instant motion went relative while `SPA_META_Cursor` kept updating), so a
|
||||
/// capture-latched client (which never advertises `CLIENT_CAP_CURSOR`, `console.rs`
|
||||
/// `latched_mouse`) streamed cursorless. Metadata + host blend is the path that was
|
||||
/// verified end-to-end; embedded remains only the can't-blend fallback (libav
|
||||
/// VAAPI/NVENC, software).
|
||||
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
|
||||
/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made
|
||||
/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session.
|
||||
pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool {
|
||||
pub(crate) fn cursor_blend_for(
|
||||
cursor_forward: bool,
|
||||
gamescope: bool,
|
||||
codec: crate::encode::Codec,
|
||||
bit_depth: u8,
|
||||
) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (cursor_forward, gamescope);
|
||||
let _ = (cursor_forward, gamescope, codec, bit_depth);
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
cursor_forward || gamescope_needs_host_cursor(gamescope)
|
||||
if gamescope {
|
||||
// gamescope's capture carries no SPA_META_Cursor; the blend-capable term below
|
||||
// must not apply, or a patch-2+ gamescope (composites its own pointer) would lose
|
||||
// its native-NV12 zero-copy shape for a blend that can never receive an overlay.
|
||||
return gamescope_needs_host_cursor(true);
|
||||
}
|
||||
if cursor_forward {
|
||||
return true;
|
||||
}
|
||||
// No cursor channel: the same CUDA-payload prediction `handshake::cursor_forward` and
|
||||
// the GameStream monitor mirror make — the NVIDIA resolution plus the zero-copy master
|
||||
// switch — deciding direct-SDK NVENC (blends) vs libav NVENC (doesn't).
|
||||
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
|
||||
crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Host **update check** (design `host-update-from-web-console.md`, phase U0).
|
||||
//!
|
||||
//! This module answers one question for the console: *does a newer host release exist for
|
||||
//! this box's channel* — by fetching the per-channel signed manifest and verifying it against
|
||||
//! the Ed25519 keys pinned below. It deliberately contains **no apply code**: U0 ships check
|
||||
//! everywhere; apply legs land per-channel (U1 Windows, U2 Linux helper) behind the same
|
||||
//! status surface.
|
||||
//!
|
||||
//! Shape: a process-wide cache + a lazy refresh. `GET /update/status` returns the cache and,
|
||||
//! when it is older than [`AUTO_REFRESH_AFTER`], kicks a background refresh — the console
|
||||
//! polls status anyway, so freshness needs no timer of its own. `POST /update/check` forces a
|
||||
//! refresh, rate-limited to one per [`FORCE_MIN_INTERVAL`].
|
||||
//!
|
||||
//! Trust and failure rules live in [`manifest`]; the serial floor persisted here
|
||||
//! (`update-state.json`) is what makes a replayed older manifest an *error*, not a silent
|
||||
//! downgrade of our knowledge. `PUNKTFUNK_UPDATE_CHECK=0` disables all network activity —
|
||||
//! status then reports `check_disabled` and carries whatever identity facts need no network.
|
||||
|
||||
pub(crate) mod detect;
|
||||
pub(crate) mod manifest;
|
||||
|
||||
use crate::store::index::PublicKey;
|
||||
use manifest::Manifest;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// The Ed25519 public keys this binary trusts for update manifests — two slots so a key
|
||||
/// rotation is "sign with the new one, ship a host trusting both, retire the old" (the
|
||||
/// plugin-store `OFFICIAL_KEYS` drill). The private half is the `UPDATE_MANIFEST_KEY` CI
|
||||
/// secret; it also lives in the operator's offline backup (plan U0.1 DoD).
|
||||
pub(crate) const UPDATE_KEYS: [&str; 2] = [
|
||||
"ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=",
|
||||
"", // rotation slot
|
||||
];
|
||||
|
||||
/// Feed base — `<base>/<channel>/manifest.json` + `.sig`. Override for tests/dev feeds via
|
||||
/// `PUNKTFUNK_UPDATE_FEED` (a base URL, not request-time input: env is operator config).
|
||||
const DEFAULT_FEED_BASE: &str = "https://git.unom.io/api/packages/unom/generic/punktfunk-update";
|
||||
|
||||
/// A cache older than this is refreshed in the background on the next status read.
|
||||
const AUTO_REFRESH_AFTER: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
|
||||
/// Forced checks (`POST /update/check`) are rate-limited to one per this interval.
|
||||
pub(crate) const FORCE_MIN_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A manifest whose publish serial is older than this is flagged stale in status — the
|
||||
/// freeze-detection hint (design §3.2), not an error.
|
||||
const STALE_AFTER: Duration = Duration::from_secs(45 * 24 * 60 * 60);
|
||||
|
||||
/// One fetch's wall-clock budget (mirrors the store catalog fetch).
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Update checks disabled by operator config (env or `host.env`).
|
||||
pub(crate) fn check_disabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("PUNKTFUNK_UPDATE_CHECK").as_deref(),
|
||||
Ok("0") | Ok("false") | Ok("off")
|
||||
)
|
||||
}
|
||||
|
||||
fn feed_base() -> String {
|
||||
std::env::var("PUNKTFUNK_UPDATE_FEED")
|
||||
.ok()
|
||||
.filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1"))
|
||||
.unwrap_or_else(|| DEFAULT_FEED_BASE.to_string())
|
||||
}
|
||||
|
||||
fn pinned_keys() -> Vec<PublicKey> {
|
||||
UPDATE_KEYS
|
||||
.iter()
|
||||
.filter(|k| !k.is_empty())
|
||||
.filter_map(|k| PublicKey::parse(k).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- runtime state
|
||||
|
||||
/// What the last successful refresh produced.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Checked {
|
||||
pub manifest: Manifest,
|
||||
pub fetched_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Runtime {
|
||||
checked: Option<Checked>,
|
||||
last_error: Option<String>,
|
||||
/// Refresh in flight (status kicks at most one).
|
||||
refreshing: bool,
|
||||
/// Wall-clock guard for the forced-check rate limit.
|
||||
last_forced: Option<Instant>,
|
||||
/// Last attempt of any kind — drives the auto-refresh cadence.
|
||||
last_attempt: Option<Instant>,
|
||||
/// The manifest version an `update.available` event was already emitted for, so a
|
||||
/// steady-state "newer exists" doesn't re-announce every 6 h.
|
||||
announced: Option<String>,
|
||||
}
|
||||
|
||||
fn runtime() -> &'static Mutex<Runtime> {
|
||||
static RT: OnceLock<Mutex<Runtime>> = OnceLock::new();
|
||||
RT.get_or_init(|| Mutex::new(Runtime::default()))
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- serial floor
|
||||
|
||||
/// Persisted anti-rollback state: the highest manifest serial ever accepted per channel.
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
struct FloorFile {
|
||||
#[serde(default)]
|
||||
serial_floor: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
pf_paths::config_dir().join("update-state.json")
|
||||
}
|
||||
|
||||
fn load_floor(path: &Path, channel: &str) -> u64 {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<FloorFile>(&b).ok())
|
||||
.and_then(|f| f.serial_floor.get(channel).copied())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it.
|
||||
fn store_floor(path: &Path, channel: &str, serial: u64) {
|
||||
let mut file: FloorFile = std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let slot = file.serial_floor.entry(channel.to_string()).or_insert(0);
|
||||
if serial <= *slot {
|
||||
return;
|
||||
}
|
||||
*slot = serial;
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(&file) else {
|
||||
return;
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- refresh
|
||||
|
||||
/// Fetch + verify the channel manifest. Blocking (`ureq`) — call from a blocking thread.
|
||||
fn fetch_manifest_blocking(channel: &str) -> Result<Manifest, String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
// Follow the registry's 303-to-object-storage redirect; the signature is verified
|
||||
// over the FINAL bytes (the sysext-feed lesson).
|
||||
.redirects(3)
|
||||
.user_agent(&format!(
|
||||
"punktfunk-host/{} (update-check)",
|
||||
env!("PUNKTFUNK_VERSION")
|
||||
))
|
||||
.build();
|
||||
let base = feed_base();
|
||||
let url = format!("{base}/{channel}/manifest.json");
|
||||
let sig_url = format!("{url}.sig");
|
||||
|
||||
let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?;
|
||||
let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?;
|
||||
let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?;
|
||||
|
||||
let keys = pinned_keys();
|
||||
if keys.is_empty() {
|
||||
// Both slots empty would mean a build with the feature disarmed; refuse rather than
|
||||
// silently skipping verification.
|
||||
return Err("no update key is pinned in this build".into());
|
||||
}
|
||||
manifest::verify_and_parse(&body, &sig_text, &keys, channel).map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
fn fetch_err(e: ureq::Error) -> String {
|
||||
match e {
|
||||
ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"),
|
||||
other => format!("feed fetch failed: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_capped(resp: ureq::Response) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read as _;
|
||||
let mut buf = Vec::new();
|
||||
let mut reader = resp
|
||||
.into_reader()
|
||||
.take(manifest::MAX_MANIFEST_BYTES as u64 + 1);
|
||||
reader
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("read failed: {e}"))?;
|
||||
if buf.len() > manifest::MAX_MANIFEST_BYTES {
|
||||
return Err("response exceeds the manifest size cap".into());
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache,
|
||||
/// announce a newly available release on the event bus. Returns the user-facing error string
|
||||
/// on failure (also cached for status).
|
||||
pub(crate) fn refresh_blocking() -> Result<Checked, String> {
|
||||
let (kind, channel) = detect::detect();
|
||||
let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| {
|
||||
let path = state_path();
|
||||
let floor = load_floor(&path, channel.as_str());
|
||||
if m.serial < floor {
|
||||
return Err(format!(
|
||||
"manifest serial {} is older than the last accepted {} — refusing rollback",
|
||||
m.serial, floor
|
||||
));
|
||||
}
|
||||
store_floor(&path, channel.as_str(), m.serial);
|
||||
Ok(m)
|
||||
});
|
||||
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
rt.last_attempt = Some(Instant::now());
|
||||
rt.refreshing = false;
|
||||
match result {
|
||||
Ok(m) => {
|
||||
let checked = Checked {
|
||||
manifest: m,
|
||||
fetched_unix: now_unix(),
|
||||
};
|
||||
let newer = detect::is_newer(
|
||||
&checked.manifest.version,
|
||||
checked.manifest.ci_run,
|
||||
env!("PUNKTFUNK_VERSION"),
|
||||
channel,
|
||||
);
|
||||
if newer && rt.announced.as_deref() != Some(checked.manifest.version.as_str()) {
|
||||
rt.announced = Some(checked.manifest.version.clone());
|
||||
crate::events::emit(crate::events::EventKind::UpdateAvailable {
|
||||
version: checked.manifest.version.clone(),
|
||||
channel: channel.as_str().to_string(),
|
||||
install_kind: kind.as_str().to_string(),
|
||||
});
|
||||
}
|
||||
rt.last_error = None;
|
||||
rt.checked = Some(checked.clone());
|
||||
Ok(checked)
|
||||
}
|
||||
Err(e) => {
|
||||
rt.last_error = Some(e.clone());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The status handler's read: current cache + errors, kicking a background refresh when the
|
||||
/// cache is cold and checks are enabled.
|
||||
pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot {
|
||||
let mut kick = false;
|
||||
let snap = {
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
let cold = rt
|
||||
.last_attempt
|
||||
.map(|t| t.elapsed() >= AUTO_REFRESH_AFTER)
|
||||
.unwrap_or(true);
|
||||
if cold && !rt.refreshing && !check_disabled() {
|
||||
rt.refreshing = true;
|
||||
rt.last_attempt = Some(Instant::now());
|
||||
kick = true;
|
||||
}
|
||||
Snapshot {
|
||||
checked: rt.checked.clone(),
|
||||
last_error: rt.last_error.clone(),
|
||||
}
|
||||
};
|
||||
if kick {
|
||||
// Fire-and-forget; the console's next poll reads the outcome.
|
||||
tokio::task::spawn_blocking(|| {
|
||||
let _ = refresh_blocking();
|
||||
});
|
||||
}
|
||||
snap
|
||||
}
|
||||
|
||||
/// A forced check (`POST /update/check`): rate-limited, blocking until the refresh finishes.
|
||||
pub(crate) async fn force_check() -> Result<Snapshot, ForceError> {
|
||||
if check_disabled() {
|
||||
return Err(ForceError::Disabled);
|
||||
}
|
||||
{
|
||||
let mut rt = runtime().lock().unwrap();
|
||||
if let Some(t) = rt.last_forced {
|
||||
if t.elapsed() < FORCE_MIN_INTERVAL {
|
||||
return Err(ForceError::TooSoon);
|
||||
}
|
||||
}
|
||||
rt.last_forced = Some(Instant::now());
|
||||
rt.refreshing = true;
|
||||
}
|
||||
let _ = tokio::task::spawn_blocking(refresh_blocking).await;
|
||||
let rt = runtime().lock().unwrap();
|
||||
Ok(Snapshot {
|
||||
checked: rt.checked.clone(),
|
||||
last_error: rt.last_error.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) enum ForceError {
|
||||
Disabled,
|
||||
TooSoon,
|
||||
}
|
||||
|
||||
/// What status hands to the API layer.
|
||||
pub(crate) struct Snapshot {
|
||||
pub checked: Option<Checked>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// The stale-feed hint: last successful check is fine but the manifest itself was
|
||||
/// published suspiciously long ago (freeze detection, design §3.2).
|
||||
pub(crate) fn stale(&self) -> bool {
|
||||
self.checked
|
||||
.as_ref()
|
||||
.map(|c| now_unix().saturating_sub(c.manifest.serial) > STALE_AFTER.as_secs())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn floor_roundtrip_and_monotonicity() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-update-floor-{}", std::process::id()));
|
||||
let path = dir.join("update-state.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(load_floor(&path, "stable"), 0);
|
||||
store_floor(&path, "stable", 100);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
// Lowering is a no-op.
|
||||
store_floor(&path, "stable", 50);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
// Channels are independent.
|
||||
store_floor(&path, "canary", 7);
|
||||
assert_eq!(load_floor(&path, "canary"), 7);
|
||||
assert_eq!(load_floor(&path, "stable"), 100);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_floor_file_reads_as_zero() {
|
||||
let dir = std::env::temp_dir().join(format!("pf-update-floor2-{}", std::process::id()));
|
||||
let path = dir.join("update-state.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"not json").unwrap();
|
||||
assert_eq!(load_floor(&path, "stable"), 0);
|
||||
// And writing over it recovers.
|
||||
store_floor(&path, "stable", 5);
|
||||
assert_eq!(load_floor(&path, "stable"), 5);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_keys_skip_empty_rotation_slot() {
|
||||
let keys = pinned_keys();
|
||||
assert_eq!(keys.len(), 1, "one live key, one empty rotation slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_math() {
|
||||
let mk = |serial| Snapshot {
|
||||
checked: Some(Checked {
|
||||
manifest: manifest::parse_verified(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"schema": 1, "channel": "stable", "serial": serial,
|
||||
"version": "0.23.0",
|
||||
"notes_url": "https://git.unom.io/unom/punktfunk/releases",
|
||||
}))
|
||||
.unwrap()
|
||||
.as_slice(),
|
||||
"stable",
|
||||
)
|
||||
.unwrap(),
|
||||
fetched_unix: now_unix(),
|
||||
}),
|
||||
last_error: None,
|
||||
};
|
||||
assert!(!mk(now_unix()).stale());
|
||||
assert!(mk(now_unix() - STALE_AFTER.as_secs() - 10).stale());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
//! **How was this host installed, and on which channel?** (design §4.1)
|
||||
//!
|
||||
//! The apply strategy — and, until apply lands, the command hint the console shows — hangs
|
||||
//! off the install kind. Detection is a ladder over root-owned facts: packaging writes a
|
||||
//! marker (`/usr/share/punktfunk/install-kind`, e.g. `apt stable`), the sysext self-identifies
|
||||
//! via its merged extension-release, Nix by store path, and so on. The API only ever *reads*
|
||||
//! this; nothing request-side can influence it.
|
||||
//!
|
||||
//! The ladder itself is a pure function over a [`Probe`] so every branch is unit-testable;
|
||||
//! [`detect`] gathers the real probe once per process.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Where the Linux packages stamp how they were installed. First word = kind
|
||||
/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`).
|
||||
const MARKER_PATH: &str = "/usr/share/punktfunk/install-kind";
|
||||
|
||||
/// The merged sysext names itself here (written by `build-sysext.sh`); its presence means the
|
||||
/// running `/usr` overlay came from the sysext image, regardless of any leftover marker.
|
||||
const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk";
|
||||
|
||||
/// The sysext updater's own config (`CHANNEL=stable|canary`).
|
||||
const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InstallKind {
|
||||
WindowsInstaller,
|
||||
Sysext,
|
||||
RpmOstree,
|
||||
Apt,
|
||||
Dnf,
|
||||
Pacman,
|
||||
SteamosSource,
|
||||
Nix,
|
||||
Source,
|
||||
}
|
||||
|
||||
impl InstallKind {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
InstallKind::WindowsInstaller => "windows-installer",
|
||||
InstallKind::Sysext => "sysext",
|
||||
InstallKind::RpmOstree => "rpm-ostree",
|
||||
InstallKind::Apt => "apt",
|
||||
InstallKind::Dnf => "dnf",
|
||||
InstallKind::Pacman => "pacman",
|
||||
InstallKind::SteamosSource => "steamos-source",
|
||||
InstallKind::Nix => "nix",
|
||||
InstallKind::Source => "source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Channel {
|
||||
Stable,
|
||||
Canary,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Stable => "stable",
|
||||
Channel::Canary => "canary",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The root-owned facts the ladder reads, gathered once by [`gather`] (tests build these
|
||||
/// directly).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Probe {
|
||||
/// Running on Windows (cfg, not a file).
|
||||
pub windows: bool,
|
||||
/// The running exe's path.
|
||||
pub exe: PathBuf,
|
||||
/// `$HOME`, if any.
|
||||
pub home: Option<PathBuf>,
|
||||
/// Contents of [`MARKER_PATH`], if present.
|
||||
pub marker: Option<String>,
|
||||
/// [`SYSEXT_MARKER`] exists (merged sysext overlay).
|
||||
pub sysext: bool,
|
||||
/// Contents of [`SYSEXT_CONF`], if present.
|
||||
pub sysext_conf: Option<String>,
|
||||
/// `/run/ostree-booted` exists (rpm-ostree / bootc family).
|
||||
pub ostree_booted: bool,
|
||||
}
|
||||
|
||||
fn gather() -> Probe {
|
||||
Probe {
|
||||
windows: cfg!(target_os = "windows"),
|
||||
exe: std::env::current_exe().unwrap_or_default(),
|
||||
home: std::env::var_os("HOME").map(PathBuf::from),
|
||||
marker: std::fs::read_to_string(MARKER_PATH).ok(),
|
||||
sysext: Path::new(SYSEXT_MARKER).exists(),
|
||||
sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(),
|
||||
ostree_booted: Path::new("/run/ostree-booted").exists(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder (design §4.1). Order matters and each rung is a root-owned fact:
|
||||
/// sysext overlay > Nix store path > dev/source tree > user-owned Deck build > package
|
||||
/// marker (flipped to rpm-ostree when the box is ostree-booted) > `source` fallback.
|
||||
pub(crate) fn classify(p: &Probe) -> (InstallKind, Channel) {
|
||||
if p.windows {
|
||||
// The installer is the only supported Windows delivery; a loose cargo build shows
|
||||
// itself by not living under Program Files. Channel: canary installers carry the CI
|
||||
// run as the third component (`M.m.<run>`), see `windows_channel_of`.
|
||||
let installed = p
|
||||
.exe
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase()
|
||||
.contains("\\program files\\punktfunk");
|
||||
return if installed {
|
||||
(
|
||||
InstallKind::WindowsInstaller,
|
||||
windows_channel_of(env!("PUNKTFUNK_VERSION")),
|
||||
)
|
||||
} else {
|
||||
(InstallKind::Source, Channel::Stable)
|
||||
};
|
||||
}
|
||||
|
||||
if p.sysext {
|
||||
let channel = p
|
||||
.sysext_conf
|
||||
.as_deref()
|
||||
.and_then(conf_channel)
|
||||
.unwrap_or(Channel::Stable);
|
||||
return (InstallKind::Sysext, channel);
|
||||
}
|
||||
|
||||
if p.exe.starts_with("/nix/store") {
|
||||
return (InstallKind::Nix, Channel::Stable);
|
||||
}
|
||||
|
||||
// A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the
|
||||
// Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is
|
||||
// user-owned but NOT a plain `target/` dir — that distinction is the marker here.
|
||||
let exe_str = p.exe.to_string_lossy().to_string();
|
||||
if exe_str.contains("/target/") {
|
||||
return (InstallKind::Source, Channel::Stable);
|
||||
}
|
||||
if let Some(home) = &p.home {
|
||||
if p.exe.starts_with(home) {
|
||||
return (InstallKind::SteamosSource, Channel::Canary);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(marker) = &p.marker {
|
||||
let mut words = marker.split_whitespace();
|
||||
let kind = words.next().unwrap_or("");
|
||||
let channel = match words.next() {
|
||||
Some("canary") => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
};
|
||||
let kind = match kind {
|
||||
"apt" => Some(InstallKind::Apt),
|
||||
// An ostree-booted box consumed the RPM by layering (or an image build); either
|
||||
// way `dnf upgrade` is not how it updates. bootc-vs-layered is refined in U2 via
|
||||
// `rpm-ostree status` — until then both report `rpm-ostree` (notify text is
|
||||
// identical in U0).
|
||||
"dnf" if p.ostree_booted => Some(InstallKind::RpmOstree),
|
||||
"dnf" => Some(InstallKind::Dnf),
|
||||
"pacman" => Some(InstallKind::Pacman),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = kind {
|
||||
return (kind, channel);
|
||||
}
|
||||
}
|
||||
|
||||
(InstallKind::Source, Channel::Stable)
|
||||
}
|
||||
|
||||
/// `CHANNEL=canary` in `/etc/punktfunk-sysext.conf` (the sysext updater's own format).
|
||||
fn conf_channel(conf: &str) -> Option<Channel> {
|
||||
for line in conf.lines() {
|
||||
if let Some(v) = line.trim().strip_prefix("CHANNEL=") {
|
||||
return Some(match v.trim() {
|
||||
"canary" => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows canary installers are versioned `M.m.<run>` where `<run>` is a 4+ digit CI run
|
||||
/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10).
|
||||
fn windows_channel_of(version: &str) -> Channel {
|
||||
match triple(version) {
|
||||
Some((_, _, patch)) if patch >= 1000 => Channel::Canary,
|
||||
_ => Channel::Stable,
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide answer, computed once.
|
||||
pub(crate) fn detect() -> (InstallKind, Channel) {
|
||||
static DETECTED: OnceLock<(InstallKind, Channel)> = OnceLock::new();
|
||||
*DETECTED.get_or_init(|| classify(&gather()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- version comparison
|
||||
|
||||
/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`).
|
||||
pub(crate) fn triple(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = v
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|s| !s.is_empty());
|
||||
// Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three
|
||||
// ONLY if the string actually starts with digits (else it's not a version at all).
|
||||
if !v.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// The CI run number embedded in a canary version string, wherever the channel's format hid
|
||||
/// it: `0.23.0~ci10250.g<sha>` (deb), `0.23.0-0.ci10250.g<sha>` (rpm), `0.23.10250`
|
||||
/// (Windows/decky style, run-as-patch). A stable string yields `None`.
|
||||
pub(crate) fn canary_run(version: &str) -> Option<u64> {
|
||||
// `ci` immediately followed by digits, anywhere.
|
||||
let mut rest = version;
|
||||
while let Some(pos) = rest.find("ci") {
|
||||
let digits: String = rest[pos + 2..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if !digits.is_empty() {
|
||||
return digits.parse().ok();
|
||||
}
|
||||
rest = &rest[pos + 2..];
|
||||
}
|
||||
match triple(version) {
|
||||
Some((_, _, patch)) if patch >= 1000 => Some(patch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the manifest's release newer than what this process runs? Definitive-or-false: an
|
||||
/// unparseable pair never flags (the console still shows both version strings — the badge
|
||||
/// just doesn't light up on guesswork). Canary compares `(major, minor)` then the CI run,
|
||||
/// because canary patch fields mean different things per channel (R10).
|
||||
pub(crate) fn is_newer(
|
||||
manifest_version: &str,
|
||||
manifest_ci_run: Option<u64>,
|
||||
current: &str,
|
||||
channel: Channel,
|
||||
) -> bool {
|
||||
let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else {
|
||||
return false;
|
||||
};
|
||||
match channel {
|
||||
Channel::Stable => m > c,
|
||||
Channel::Canary => {
|
||||
if (m.0, m.1) != (c.0, c.1) {
|
||||
return (m.0, m.1) > (c.0, c.1);
|
||||
}
|
||||
let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version));
|
||||
match (manifest_run, canary_run(current)) {
|
||||
(Some(mr), Some(cr)) => mr > cr,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-kind "how to update" command the console shows while (or instead of) an apply
|
||||
/// path existing (design §5). One line, copy-pastable, no placeholders.
|
||||
pub(crate) fn channel_hint(kind: InstallKind) -> &'static str {
|
||||
match kind {
|
||||
InstallKind::WindowsInstaller => {
|
||||
"winget upgrade unom.PunktfunkHost (or re-run the newer installer)"
|
||||
}
|
||||
InstallKind::Sysext => "sudo punktfunk-sysext update",
|
||||
InstallKind::RpmOstree => {
|
||||
"sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)"
|
||||
}
|
||||
InstallKind::Apt => "sudo apt update && sudo apt install --only-upgrade punktfunk-host",
|
||||
InstallKind::Dnf => "sudo dnf upgrade punktfunk",
|
||||
InstallKind::Pacman => "sudo pacman -Syu",
|
||||
InstallKind::SteamosSource => "bash ~/punktfunk/scripts/steamdeck/update.sh --pull",
|
||||
InstallKind::Nix => "nix flake update punktfunk (then rebuild your system)",
|
||||
InstallKind::Source => "git pull && cargo build --release -p punktfunk-host",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn probe() -> Probe {
|
||||
Probe {
|
||||
windows: false,
|
||||
exe: PathBuf::from("/usr/bin/punktfunk-host"),
|
||||
home: Some(PathBuf::from("/home/deck")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_sysext_beats_marker() {
|
||||
let mut p = probe();
|
||||
p.sysext = true;
|
||||
p.marker = Some("dnf canary".into());
|
||||
p.sysext_conf = Some("CHANNEL=canary\n".into());
|
||||
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Canary));
|
||||
p.sysext_conf = None;
|
||||
assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_nix_store_path() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::Nix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_cargo_target_is_source_even_under_home() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::Source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_deck_build_is_steamos_source() {
|
||||
let mut p = probe();
|
||||
p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host");
|
||||
assert_eq!(classify(&p).0, InstallKind::SteamosSource);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_markers() {
|
||||
for (marker, ostree, kind, channel) in [
|
||||
("apt stable", false, InstallKind::Apt, Channel::Stable),
|
||||
("apt canary", false, InstallKind::Apt, Channel::Canary),
|
||||
("dnf stable", false, InstallKind::Dnf, Channel::Stable),
|
||||
("dnf stable", true, InstallKind::RpmOstree, Channel::Stable),
|
||||
("pacman canary", false, InstallKind::Pacman, Channel::Canary),
|
||||
] {
|
||||
let mut p = probe();
|
||||
p.marker = Some(marker.into());
|
||||
p.ostree_booted = ostree;
|
||||
assert_eq!(classify(&p), (kind, channel), "marker `{marker}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ladder_unknown_marker_falls_through_to_source() {
|
||||
let mut p = probe();
|
||||
p.marker = Some("snap stable".into());
|
||||
assert_eq!(classify(&p).0, InstallKind::Source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triples() {
|
||||
assert_eq!(triple("0.23.0"), Some((0, 23, 0)));
|
||||
assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0)));
|
||||
assert_eq!(triple("0.23.10250"), Some((0, 23, 10250)));
|
||||
assert_eq!(triple("garbage"), None);
|
||||
assert_eq!(triple("1.2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canary_runs() {
|
||||
assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250));
|
||||
assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777));
|
||||
assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky)
|
||||
assert_eq!(canary_run("0.23.0"), None); // stable string
|
||||
assert_eq!(canary_run("0.23.0-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_stable() {
|
||||
assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable));
|
||||
assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable));
|
||||
assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags
|
||||
assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_canary_compares_runs_not_patch() {
|
||||
// deb canary current vs Windows-style manifest version, same run ⇒ NOT newer,
|
||||
// even though a naive triple compare says 10250 > 0.
|
||||
assert!(!is_newer(
|
||||
"0.23.10250",
|
||||
Some(10250),
|
||||
"0.23.0~ci10250.gab12cd34",
|
||||
Channel::Canary
|
||||
));
|
||||
assert!(is_newer(
|
||||
"0.23.10251",
|
||||
Some(10251),
|
||||
"0.23.0~ci10250.gab12cd34",
|
||||
Channel::Canary
|
||||
));
|
||||
// Minor bump wins outright.
|
||||
assert!(is_newer(
|
||||
"0.24.100",
|
||||
Some(100),
|
||||
"0.23.0~ci10250.g12",
|
||||
Channel::Canary
|
||||
));
|
||||
// No run extractable on either side ⇒ conservative false.
|
||||
assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_channel_heuristic() {
|
||||
assert_eq!(windows_channel_of("0.22.2"), Channel::Stable);
|
||||
assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The signed **update manifest**: the check truth for "a newer host exists"
|
||||
//! (design `host-update-from-web-console.md` §3).
|
||||
//!
|
||||
//! One small JSON document per channel, Ed25519-signed with keys pinned in this binary
|
||||
//! ([`super::UPDATE_KEYS`]) and verified by the exact code path the plugin store already
|
||||
//! trusts ([`crate::store::index::verify_signature`]). TLS and the registry that serves the
|
||||
//! document are transport, never trust.
|
||||
//!
|
||||
//! Rules, all fail-closed (the sysext 303 lesson encoded):
|
||||
//! 1. **Signature before parse** — over the exact fetched bytes, then strict JSON. An HTML
|
||||
//! error page, a redirect stub, or a truncated body dies before any field is read.
|
||||
//! 2. **Channel binding** — the document names its channel and it must match the one we
|
||||
//! asked for, so a validly-signed canary manifest replayed onto the stable URL is refused.
|
||||
//! 3. **Monotonic serial** — the publish-time serial can never go backwards for a channel
|
||||
//! (the anti-downgrade/anti-replay floor, persisted by [`super`]).
|
||||
//! 4. **Pinned notes origin** — the release-notes link the console renders must live on our
|
||||
//! forge, so a signed-but-wrong document can't send the operator to a lookalike page.
|
||||
|
||||
use crate::store::index::{verify_signature, PublicKey};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The only manifest schema this host understands. A breaking change bumps this; old hosts
|
||||
/// report "unsupported schema" instead of guessing.
|
||||
pub(crate) const SCHEMA: u32 = 1;
|
||||
|
||||
/// Hard cap on a fetched manifest (and its signature). The real document is <1 KB.
|
||||
pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// The only origin a manifest may point the operator at for release notes.
|
||||
const NOTES_ORIGIN: &str = "https://git.unom.io/";
|
||||
|
||||
/// The signed update manifest, as served (and signed) per channel.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct Manifest {
|
||||
/// Document schema — must equal [`SCHEMA`].
|
||||
pub schema: u32,
|
||||
/// The channel this document was published for (`stable` | `canary`). Bound-checked
|
||||
/// against the channel we fetched, see module docs rule 2.
|
||||
pub channel: String,
|
||||
/// Unix seconds at publish. Strictly increasing per channel; also drives the stale-feed
|
||||
/// hint (freshness needs no date parsing and no trust in `published_at`).
|
||||
pub serial: u64,
|
||||
/// RFC-3339 publish time. Display only.
|
||||
#[serde(default)]
|
||||
pub published_at: String,
|
||||
/// The released host version this manifest announces.
|
||||
pub version: String,
|
||||
/// Release-notes link the console renders. Must be on [`NOTES_ORIGIN`].
|
||||
#[serde(default)]
|
||||
pub notes_url: String,
|
||||
/// Canary only: the CI run number, the definitive "newer" axis where per-channel version
|
||||
/// strings differ (`~ciN`, `0.ciN`, a padded pkgrel, `M.m.run`).
|
||||
#[serde(default)]
|
||||
pub ci_run: Option<u64>,
|
||||
/// The Windows installer leg (design §6) — parsed and carried now so a U0 host is already
|
||||
/// schema-complete, consumed by the U1 apply path.
|
||||
#[serde(default)]
|
||||
pub windows_host: Option<WindowsHostAsset>,
|
||||
}
|
||||
|
||||
/// Where the Windows host installer for [`Manifest::version`] lives and how to verify it.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct WindowsHostAsset {
|
||||
/// Immutable per-version download URL — never a mutable `latest/` alias, so the hash
|
||||
/// below can't race an alias re-upload.
|
||||
pub url: String,
|
||||
/// SHA-256 (hex) of the installer bytes.
|
||||
pub sha256: String,
|
||||
/// Accepted Authenticode signing-leaf SHA-256 fingerprints. Living in the signed manifest
|
||||
/// (not the binary) is what makes the self-signed → Trusted Signing migration a manifest
|
||||
/// edit instead of a lockstep host release.
|
||||
#[serde(default)]
|
||||
pub authenticode_sha256: Vec<String>,
|
||||
/// Minimum Windows build (display/preflight only).
|
||||
#[serde(default)]
|
||||
pub min_os: String,
|
||||
}
|
||||
|
||||
/// Verify `sig_text` over the exact `bytes` against `keys`, then strictly parse and validate
|
||||
/// the document for `expected_channel`. The only constructor — there is no unsigned path.
|
||||
pub(crate) fn verify_and_parse(
|
||||
bytes: &[u8],
|
||||
sig_text: &str,
|
||||
keys: &[PublicKey],
|
||||
expected_channel: &str,
|
||||
) -> Result<Manifest> {
|
||||
verify_signature(bytes, sig_text, keys).context("update manifest signature")?;
|
||||
parse_verified(bytes, expected_channel)
|
||||
}
|
||||
|
||||
/// Parse + validate a document whose signature has already been checked. Split out so tests
|
||||
/// can exercise validation without minting signatures for every case.
|
||||
pub(crate) fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result<Manifest> {
|
||||
if bytes.len() > MAX_MANIFEST_BYTES {
|
||||
bail!("manifest is larger than the {MAX_MANIFEST_BYTES}-byte cap");
|
||||
}
|
||||
let m: Manifest = serde_json::from_slice(bytes).context("update manifest is not valid JSON")?;
|
||||
if m.schema != SCHEMA {
|
||||
bail!(
|
||||
"unsupported manifest schema {} (this host understands {SCHEMA})",
|
||||
m.schema
|
||||
);
|
||||
}
|
||||
if m.channel != expected_channel {
|
||||
bail!(
|
||||
"manifest is for channel `{}` but this host asked for `{expected_channel}`",
|
||||
m.channel
|
||||
);
|
||||
}
|
||||
if m.version.is_empty() || m.version.len() > 64 {
|
||||
bail!("manifest version is empty or implausibly long");
|
||||
}
|
||||
if m.serial == 0 {
|
||||
bail!("manifest serial is zero");
|
||||
}
|
||||
if !m.notes_url.is_empty() && !m.notes_url.starts_with(NOTES_ORIGIN) {
|
||||
bail!("manifest notes_url is not on {NOTES_ORIGIN}");
|
||||
}
|
||||
if let Some(w) = &m.windows_host {
|
||||
if !w.url.starts_with("https://") {
|
||||
bail!("windows_host.url must be https");
|
||||
}
|
||||
if w.sha256.len() != 64 || !w.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
bail!("windows_host.sha256 is not a hex SHA-256");
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"schema": 1,
|
||||
"channel": "stable",
|
||||
"serial": 1785400000u64,
|
||||
"published_at": "2026-07-30T12:00:00Z",
|
||||
"version": "0.23.0",
|
||||
"notes_url": "https://git.unom.io/unom/punktfunk/releases/tag/v0.23.0",
|
||||
"windows_host": {
|
||||
"url": "https://git.unom.io/unom/punktfunk/releases/download/v0.23.0/punktfunk-host-setup-0.23.0.exe",
|
||||
"sha256": "aa".repeat(32),
|
||||
"authenticode_sha256": ["bb".repeat(32)],
|
||||
"min_os": "10.0.22621"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn bytes(v: &serde_json::Value) -> Vec<u8> {
|
||||
serde_json::to_vec(v).unwrap()
|
||||
}
|
||||
|
||||
/// End-to-end: sign with a fresh ring keypair, verify with the matching pinned-key
|
||||
/// string — the format contract with the CI signer (raw 64-byte sig, base64; raw
|
||||
/// 32-byte key, `ed25519:<base64>`).
|
||||
#[test]
|
||||
fn signed_roundtrip_and_tamper() {
|
||||
use base64::Engine as _;
|
||||
use ring::signature::KeyPair as _;
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
|
||||
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
|
||||
let key_str = format!(
|
||||
"ed25519:{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref())
|
||||
);
|
||||
let keys = vec![PublicKey::parse(&key_str).unwrap()];
|
||||
|
||||
let body = bytes(&doc());
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(kp.sign(&body));
|
||||
|
||||
let m = verify_and_parse(&body, &sig, &keys, "stable").unwrap();
|
||||
assert_eq!(m.version, "0.23.0");
|
||||
assert_eq!(
|
||||
m.windows_host.as_ref().unwrap().authenticode_sha256.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// One flipped byte ⇒ refused before parse.
|
||||
let mut tampered = body.clone();
|
||||
tampered[10] ^= 1;
|
||||
assert!(verify_and_parse(&tampered, &sig, &keys, "stable").is_err());
|
||||
|
||||
// Signed for stable, replayed as canary ⇒ refused (channel binding).
|
||||
assert!(verify_and_parse(&body, &sig, &keys, "canary").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_error_page_is_not_a_manifest() {
|
||||
// The Gitea 303 stub that poisoned the sysext feed — must die in strict parse.
|
||||
let html = b"<a href=\"https://objects.example/x\">See Other</a>.";
|
||||
assert!(parse_verified(html, "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_json_refused() {
|
||||
let body = bytes(&doc());
|
||||
assert!(parse_verified(&body[..body.len() - 5], "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_schema_refused() {
|
||||
let mut v = doc();
|
||||
v["schema"] = serde_json::json!(2);
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_serial_refused() {
|
||||
let mut v = doc();
|
||||
v["serial"] = serde_json::json!(0);
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsite_notes_url_refused() {
|
||||
let mut v = doc();
|
||||
v["notes_url"] = serde_json::json!("https://evil.example/notes");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_asset_validation() {
|
||||
let mut v = doc();
|
||||
v["windows_host"]["sha256"] = serde_json::json!("nothex");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
let mut v = doc();
|
||||
v["windows_host"]["url"] = serde_json::json!("http://git.unom.io/x.exe");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
// No windows leg at all is fine (PM channels don't need it).
|
||||
let mut v = doc();
|
||||
v.as_object_mut().unwrap().remove("windows_host");
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_refused() {
|
||||
let mut v = doc();
|
||||
v["published_at"] = serde_json::json!("x".repeat(MAX_MANIFEST_BYTES));
|
||||
assert!(parse_verified(&bytes(&v), "stable").is_err());
|
||||
}
|
||||
}
|
||||
@@ -471,7 +471,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
"firewall",
|
||||
"delete",
|
||||
"rule",
|
||||
"name=punktfunk web console (TCP 47992)",
|
||||
"name=Punktfunk web console (TCP 47992)",
|
||||
],
|
||||
);
|
||||
if !run_quiet(
|
||||
@@ -481,7 +481,7 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
"firewall",
|
||||
"add",
|
||||
"rule",
|
||||
"name=punktfunk web console (TCP 47992)",
|
||||
"name=Punktfunk web console (TCP 47992)",
|
||||
"dir=in",
|
||||
"action=allow",
|
||||
"protocol=TCP",
|
||||
|
||||
@@ -995,7 +995,7 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
("UDP", "UDP", "47998-48010,9777,5353"),
|
||||
];
|
||||
for (suffix, proto, ports) in rules {
|
||||
let name = format!("punktfunk {suffix}");
|
||||
let name = format!("Punktfunk {suffix}");
|
||||
let ok = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
@@ -1028,7 +1028,9 @@ fn add_firewall_rules(allow_public: bool) {
|
||||
|
||||
fn remove_firewall_rules() {
|
||||
for suffix in ["TCP", "UDP"] {
|
||||
let name = format!("punktfunk {suffix}");
|
||||
// Capital P is the brand; netsh matches a rule name case-INSENSITIVELY, so this still
|
||||
// reaps the lowercase rules every release up to 0.22.1 created — no orphans on upgrade.
|
||||
let name = format!("Punktfunk {suffix}");
|
||||
let _ = run_quiet(
|
||||
"netsh",
|
||||
&[
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"steamos-host",
|
||||
"windows-host",
|
||||
"web-console",
|
||||
"updating",
|
||||
"---Configure your desktop---",
|
||||
"configuration",
|
||||
"kde",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Updating the Host
|
||||
description: How to see when a newer punktfunk host is available — the web console's update card — and the update command for every install method.
|
||||
---
|
||||
|
||||
The web console tells you when a newer host is out. The **Host** page has an **Updates** card
|
||||
showing the version you run, the channel you follow (stable or canary), how this host was
|
||||
installed, and — once a newer release exists — the exact command that updates it. The
|
||||
"update available" state also fires an `update.available` event on the host
|
||||
[event stream](/docs/automation), so hooks and scripts can react to it too.
|
||||
|
||||
The check is a small signed manifest the host fetches from the punktfunk release feed and
|
||||
verifies against keys built into the host itself — a tampered or replayed feed is rejected, and
|
||||
the console will tell you when a check failed rather than silently showing stale facts.
|
||||
|
||||
## Updating, per install method
|
||||
|
||||
The console shows the right one of these automatically; for reference:
|
||||
|
||||
| How you installed | How to update |
|
||||
|---|---|
|
||||
| Windows installer / winget | `winget upgrade unom.PunktfunkHost`, or run the newer `punktfunk-host-setup.exe` |
|
||||
| Ubuntu / Debian (apt) | `sudo apt update && sudo apt install --only-upgrade punktfunk-host` |
|
||||
| Fedora (dnf) | `sudo dnf upgrade punktfunk` |
|
||||
| Bazzite sysext (recommended) | `sudo punktfunk-sysext update` |
|
||||
| Bazzite rpm-ostree layer | `sudo /usr/share/punktfunk/update-punktfunk.sh` (staged — reboot to finish) |
|
||||
| Arch / CachyOS (pacman) | `sudo pacman -Syu` (a normal full system upgrade) |
|
||||
| Steam Deck (on-device build) | `bash ~/punktfunk/scripts/steamdeck/update.sh --pull` |
|
||||
| NixOS | update the flake input and rebuild |
|
||||
|
||||
After a Linux package update, restart the host to pick up the new binary:
|
||||
|
||||
```bash
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
(The Windows installer restarts the service itself; `punktfunk-sysext update` prints the same
|
||||
restart hint when it's needed.)
|
||||
|
||||
One-click updating from the console is on the way, per install method — the card will grow an
|
||||
**Apply** button where the platform supports it.
|
||||
|
||||
## Turning the check off
|
||||
|
||||
The check contacts `git.unom.io` (the punktfunk forge) and nothing else, and sends nothing but a
|
||||
normal download request. If you'd rather the host never checks, set:
|
||||
|
||||
```bash
|
||||
PUNKTFUNK_UPDATE_CHECK=0
|
||||
```
|
||||
|
||||
in the host's environment (`host.env` on Windows, the systemd user unit environment on Linux).
|
||||
The card then shows checks as disabled; everything else keeps working.
|
||||
|
||||
## If the card says the feed is stale
|
||||
|
||||
"Feed hasn't changed in over 45 days" means checks *succeed* but nothing new arrives. Usually
|
||||
that just means no release happened for a while; if the [releases page](https://git.unom.io/unom/punktfunk/releases)
|
||||
shows something newer than the card does, something between this host and the feed is pinning old
|
||||
data — worth a look at proxies or DNS on the way to `git.unom.io`.
|
||||
@@ -12,23 +12,6 @@ A machine you pin from the host's own configuration shows the choice in the cons
|
||||
|
||||
For headless and unattended setups, two new commands help you find and verify the right monitor without a screen attached: `punktfunk-host list-monitors` lists what the machine has (names, geometry, which one is pinned), and `punktfunk-host mirror-test` confirms frames are actually flowing from the one you picked.
|
||||
|
||||
## New: HDR on the gamescope path (opt-in)
|
||||
|
||||
Streaming Steam Gaming Mode from a Linux box has always been SDR — not because the encoder couldn't
|
||||
do better, but because gamescope hands its picture to Punktfunk already tone-mapped down to 8-bit.
|
||||
That gap is now closed, with a small companion package: install **`punktfunk-gamescope`** (gamescope
|
||||
plus a patch that adds the 10-bit BT.2020 PQ formats to its capture output — offered upstream) and
|
||||
set `PUNKTFUNK_GAMESCOPE_HDR=1`, and games render in real HDR while the stream carries HDR10 to an
|
||||
HDR-capable client.
|
||||
|
||||
It sits *beside* your system's gamescope rather than replacing it — your own Gaming Mode is
|
||||
untouched — and Punktfunk only uses it for the sessions it starts itself. On Bazzite it rides in
|
||||
the Punktfunk sysext; there's an Arch package, a NixOS option, and a build script for everything
|
||||
else. `punktfunk-host hdr-probe` tells you exactly which pieces are in place.
|
||||
|
||||
Opt-in for this release while it soaks: without the knob, or without the extra package, the
|
||||
gamescope path streams SDR exactly as before.
|
||||
|
||||
## Fixed: newer KDE Plasma silently fell back to a slower, less reliable way to arrange displays
|
||||
|
||||
On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the desktop about display layout stopped seeing any displays at all, with no visible error — every session quietly fell back to an external helper tool that's known to hang under load, exactly the thing the previous release's Plasma fix was meant to stop relying on. Fixed: Punktfunk now recognizes both the way older and newer Plasma releases announce their displays.
|
||||
@@ -42,16 +25,6 @@ On current KDE Plasma (6.7 and newer), Punktfunk's direct way of talking to the
|
||||
- **libei absolute-coordinate resolution changed from matching by mode size to `mapping_id` → origin → size → first**, since two outputs can share a size but never a top-left, and a mirrored head's region is not the client's stream size at all. A named anchor that matches nothing falls back down the ladder rather than stranding input, and both a miss and a match now log once per distinct answer. `punktfunk-host anchor-test` exercises the ladder against a live compositor with two same-sized outputs — the one case a unit test can only simulate.
|
||||
- **`SWAYSOCK` is now derived rather than required to be inherited** — by the compositor's known PID, then the newest socket owned by the host's user — closing the last session variable a `systemd --user` host lacked for sway. A new drop-in (`PartOf`/`WantedBy` on `graphical-session.target`, opt-in, shipped under `/usr/share`) restarts a desktop-login host with its desktop, so a Wayland socket and portal connection that die with a Plasma/GNOME restart don't leave the daemon silently unable to recover.
|
||||
- **KWin's in-process output-management path now also binds `kde_output_device_registry_v2`**, not just the per-output `kde_output_device_v2` globals it originally shipped with — KWin 6.7 stopped advertising the latter, so the module written specifically to avoid shelling out to `kscreen-doctor` (0.19.x) saw zero devices and silently degraded to it on every session. Both models are supported now; registry-sourced devices arrive one Wayland round-trip later, so the handshake gains one conditional extra barrier.
|
||||
- **gamescope HDR is decided statically, before the display exists.** The punktfunk/1 Welcome fixes a session's bit depth up front and cannot take it back (PQ frames on an 8-bit encoder are a deliberate hard error), so the capability answer is the identity of the gamescope binary the host will spawn — a `+pfhdr` marker in its `--version` banner, probed once per boot — never an optimistic negotiation. The capture-side gate became source-aware (`capturer_supports_hdr_for(compositor)`), the HDR negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa), and the keep-alive reuse key gained `hdr` so a display brought up SDR can never be handed to an HDR session. The GameStream plane's live BT.2100 monitor probe is now scoped to the portal source — a headless gamescope box has no monitor to be in HDR mode.
|
||||
- **The gamescope patch mirrors code already in gamescope's tree.** Its PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with MANDATORY SMPTE ST.2084 + BT.2020 properties, mapped to `DRM_FORMAT_XRGB2101010`/`XBGR2101010` — the same 10-bit capture texture the HDR AVIF screenshot path allocates — and `paint_pipewire()` composites into them with the HDR screenshot LUT set and `EOTF_PQ`, which is exactly the `bHDRScreenshot` branch. The new formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit, and the fixed PQ container is deliberately *not* conditional on the focused app being HDR (a stream's colourimetry must not follow what the game happens to render).
|
||||
- **Vulkan Video learned 10-bit**, which is what keeps AMD/Intel HDR on the good path: an HDR session opens a 10-bit video profile (HEVC Main10 / AV1 Main at 10 bits) with a `G10X6…3PACK16` picture and DPB, headers carrying the depth and the BT.2020/PQ CICP triplet — an SPS `bit_depth_*_minus8 = 2` for HEVC, `high_bitdepth` plus the matching sequence-header OBU bits for AV1 — and a new `rgb2yuv10.comp`: the 8-bit BT.709 shader's twin, doing a pure BT.2020 NCL matrix on the already-PQ-encoded samples (there is no transfer function to apply, and applying one would be wrong) and writing 10-bit values into the high bits of `R16`/`RG16` scratch planes that are size-compatible with the picture's. That keeps the two things HDR would otherwise cost on this vendor: real RFI loss recovery, and the compute CSC's cursor blend — the only way a gamescope pointer reaches the stream at all.
|
||||
- **GameStream advertises its 10-bit codec bits per codec.** `ServerCodecModeSupport` layered `SCM_HEVC_MAIN10` and never `SCM_AV1_MAIN10` — a blanket omission on the theory that the GameStream AV1 path was unconfirmed, even though the SDR baseline has always offered AV1 Main8, so the depth was never the uncertain part. Each 10-bit bit is now gated on that codec's own `can_encode_10bit` probe AND the baseline already advertising it, and the RTSP honor degrades a session whose NEGOTIATED codec can't carry 10 bits rather than labelling an 8-bit stream PQ. `host_hdr_capable` became codec-agnostic to match (any 10-bit-capable codec makes the host HDR-capable; which one a session gets is the session's question).
|
||||
- **The Vulkan encode backend is now capability-probed per codec AND depth**, mirroring what the direct-SDK NVENC path already does with its GUID probe. `vkGetPhysicalDeviceVideoCapabilitiesKHR` is asked against the very profile the session open will build, so the dispatcher's prediction cannot disagree with reality: a device that can encode 10-bit keeps the Vulkan path, one that can't routes to libav VAAPI *before* burning a failed session open, and `can_encode_10bit` reports the union of what VAAPI and Vulkan Video can do rather than VAAPI's answer alone (which was under-reporting 10-bit on hardware that could do it).
|
||||
- **A gamescope session can now be zero-copy end to end.** gamescope keeps the pointer out of its PipeWire node (it lives on a hardware plane for scanout), so the host always reconstructed it from XFixes and blended it into every frame — and *that blend* is what forced the encode path onto its compute colour-conversion arm, since the zero-copy RGB-direct source hands the captured buffer to a fixed-function front end with no blend stage. A second carried gamescope patch adds `--pipewire-composite-cursor`, which paints it in using the same `MouseCursor::paint` call the scanout composite uses; the repaint test grows the cursor's state alongside the commit ids, so a pointer-only move still produces a frame and a hidden cursor is erased. The host reads a monotonic `+pfhdr<N>` patch level from the `--version` banner — one probe now answering both "can it do HDR" and "does it paint the cursor" — and stops attaching the XFixes reader and blending when the answer is yes.
|
||||
- **The two indirect spawn modes now verify that their flags arrived.** A host-managed `gamescope-session-plus` receives the HDR and cursor flags through `GAMESCOPE_BIN` + `PF_HDR_ARGS`, and SteamOS through a PATH shim — conventions, not guarantees. A session that ignored either would exec the distro's gamescope with none of them, and while the HDR half fails loudly (capture negotiation times out against the bit depth the Welcome already fixed), a lost `--pipewire-composite-cursor` was **silent**: the host had been told the compositor would paint the pointer, so it painted none, and the stream simply had no cursor. Both managed paths now read the running compositor's `/proc/<pid>/cmdline` once its node appears and refuse the session on a missing flag; since the plan is fixed by then (`cursor_blend` feeds the encoder open, which precedes the display), the capability is latched off for the process and the retry resolves a correct SDR host-composited session — one rejected attempt per boot, then it converges. The check fails **open** at every ambiguity: no flags expected, or no readable gamescope in `/proc`, says nothing.
|
||||
- **`punktfunk-gamescope` is now built by CI on the channels that ship it.** `rpm.yml` builds it in the matching Fedora container and hands it to `build-sysext.sh --gamescope`; `arch.yml` builds `packaging/gamescope/PKGBUILD` into the same pacman repo. Both are cached on `packaging/gamescope/**` (that tree depends on nothing else in the repo, so a normal push restores a binary rather than spending ten minutes on someone else's C++) and both are best-effort, because the packages those workflows exist to publish must not hinge on a gamescope build. The Arch PKGBUILD and the nix derivation had each drifted to a stale patch list — the PKGBUILD naming two patches when there are three, the nix override naming the level-1 banner patch after level 2 landed — so both now read the patch *directory*, and the PKGBUILD delegates the whole build to the shared script (asserting its own pinned rev matches) instead of re-deriving the meson invocation, which had already lost the `force_fallback_for=wlroots` that keeps the binary startable off the build host. The nix override's `gamescope.unwrapped` requirement was wrong on current nixpkgs, where `gamescope` *is* the buildable derivation; it now prefers `.unwrapped` where it exists and checks the result is something `overrideAttrs` can actually patch.
|
||||
- **The zero-CSC RGB-direct (EFC) source works in HDR too.** The `VK_VALVE_video_encode_rgb_conversion` probe now asks for the BT.2020 model and the captured 10-bit packed-RGB format instead of assuming BT.709/BGRA, and the session selects the matching model — so an HDR session with no pointer to composite (the GameStream desktop mirror) hands the captured buffer straight to the encoder's fixed-function front end and runs no host CSC at all. Sessions that DO composite a pointer keep the compute CSC, as before: the EFC cannot blend.
|
||||
- **NVIDIA gained a zero-copy HDR leg**, and the VAAPI fallback needed no new encoder code. The VAAPI path already ingested XR30 dmabufs into `format=p010:out_color_matrix=bt2020`. On NVIDIA the packed 10-bit frame now travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 CSC itself: no host CSC pass, no depth loss, and the cursor-blend compute shader gained two 10-bit modes so the pointer survives. The tiled EGL de-tile blit is still 8-bit and HDR never routes through it. A host without the direct-SDK NVENC backend keeps HDR on the CPU path, since libav's HDR route swscales into a P010 hardware frame that a packed-10-bit CUDA buffer cannot fill.
|
||||
- **CI-only fix:** the winget release-verification step's `envs:` allow-list was a step-level sibling of `with:`/`env:` instead of nested inside `with:`, so `appleboy/ssh-action` never actually received it as an input and the step kept failing on every tag. Moved to match how `REGISTRY_TOKEN` is already forwarded elsewhere.
|
||||
|
||||
See [v0.20.1](v0.20.1.md) for the fixes carried by that release (Windows install/winget, GameStream opt-in default, the gamescope Game Mode takeover hardening, a laptop-panel stall fix, and the PyroWave latency-creep bundle) — all included here too, since this release supersedes it.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
Update whenever it suits you — the app and the machine you stream from can be updated one at a time, and everything already paired keeps working. One exception, on Windows only: the host and its virtual-controller drivers now have to match each other. Installing or updating the host the normal way takes care of both; if you ever end up with a mismatched pair, controllers stop attaching until the drivers are updated too.
|
||||
|
||||
The headline is **settings profiles**. Until now every client setting was global, so the 4K@120 you picked for the desktop upstairs followed you to the retro box in the basement. You can now make named profiles — Game, Work, Couch — and bind one to each host, and they arrive on every client at once: Linux, Windows, Mac, iPhone, iPad, Apple TV and Android. Alongside them: `punktfunk://` links and double-clickable shortcuts that open a stream on a host you already trust, a new `punktfunk` command for scripts and plugins, and — on Linux — real HDR when you stream Steam's Gaming Mode.
|
||||
|
||||
## New: settings profiles — your settings, per host
|
||||
|
||||
A profile is a named bundle of the settings you want to be different, and only those. Anything you don't touch keeps following your defaults, so fixing a global setting once fixes it everywhere; anything you do touch stays put even if you later change the default. The only way back to inheriting is an explicit **Reset**, which every changed row offers.
|
||||
|
||||
There is one settings screen, not two. A switcher at the top swaps the whole screen between **Default settings** and one profile — same categories, same rows, same explanations — and every row shows the value that is actually in effect. Rows a profile changes are marked, so which settings a profile touches is legible without reading it against your defaults. Rows that are facts about *this device* rather than about Game-vs-Work — which decoder, which speakers, which controller is forwarded, auto-wake — simply aren't offered in a profile.
|
||||
|
||||
On the host side, three separate things you can do with a profile:
|
||||
|
||||
- **Bind one to a host.** The card wears a chip naming it, so what a plain click will do is visible without opening anything.
|
||||
- **Connect with one, just this once.** A one-off from the card's menu that never changes the binding — connecting with Work today doesn't mean Work tomorrow.
|
||||
- **Pin a host + profile as its own card.** *Desktop · Work* sits in the grid next to *Desktop*, one click away. It's a shortcut, not a second host, so pairing, Wake-on-LAN and renames stay on the primary card. On Apple TV and Android TV the same pins are tiles, which is what makes profiles usable where menus are not.
|
||||
|
||||
Profiles get a colour you pick when you name them, and it follows them everywhere the profile is named — the switcher, the card chips, the in-stream overlay. Deleting a profile never breaks anything: hosts bound to it fall back to your defaults and its pinned cards stop appearing.
|
||||
|
||||
Two long-standing annoyances go away with this. The **speed test** now writes its result into the layer the host you tested actually reads its bitrate from — measuring the slow box downstairs used to quietly re-tune your desktop — and every button says where it will write before you press it. And **Android finally has a speed test at all**, along with a per-host clipboard switch and full parity with the settings the other clients had.
|
||||
|
||||
## New: links and shortcuts that open a stream
|
||||
|
||||
`punktfunk://` links now work on every client. A browser prompt, a wiki link, an `xdg-open`, a Playnite entry, a Stream Deck macro or a shortcut on your desktop can open a stream on a host this device already trusts — optionally launching a specific game and using a specific profile.
|
||||
|
||||
Host and pinned cards can **copy their own link**, and on Linux and Windows they can **write a double-clickable shortcut** straight to your applications folder or desktop. The link carries the host's stable id *and* its address and fingerprint, so a shortcut keeps working after the host moves to a new address or your client is reinstalled.
|
||||
|
||||
A link can only ever do what clicking one of your own cards could do, minus trust decisions. It carries *references* to things that already exist on this device — never values, so no web page can dictate your resolution, bitrate or codec. It can never pair with or trust a host on its own: a link naming a host you don't know opens the ordinary PIN ceremony, under your eyes. A link that names a profile you don't have, or a fingerprint that contradicts what you already pinned, is refused by name rather than quietly connecting with the wrong thing. And a link arriving while you're already streaming never cuts that stream off.
|
||||
|
||||
## New: the `punktfunk` command
|
||||
|
||||
One command-line front end over the same brain the apps use, for scripts, plugins and headless boxes:
|
||||
|
||||
```
|
||||
punktfunk pair | hosts list/add/forget | wake | library | launch | open
|
||||
reachable | speed-test | profiles list | reset
|
||||
```
|
||||
|
||||
Because it runs the same connect plan a card click runs, `launch` and `open` **wake a sleeping host** and wait for it — the older shell flag never did; it fired a packet and dialled into the void. Exit codes are distinct enough to branch on without parsing prose, and anything that genuinely needs a person (pairing, reset) refuses rather than hanging a CI job on an invisible prompt. It's installed by the deb, rpm, Arch, nix, flatpak and Windows packages.
|
||||
|
||||
## New: HDR when you stream Steam's Gaming Mode on Linux (opt-in)
|
||||
|
||||
Streaming Steam Gaming Mode from a Linux box has always been SDR — not because the encoder couldn't do better, but because gamescope hands its picture to Punktfunk already tone-mapped down to 8-bit. That gap is now closed, with a small companion package: install **`punktfunk-gamescope`** (gamescope plus a patch that adds the 10-bit BT.2020 PQ formats to its capture output — offered upstream) and set `PUNKTFUNK_GAMESCOPE_HDR=1`, and games render in real HDR while the stream carries HDR10 to an HDR-capable client.
|
||||
|
||||
It sits *beside* your system's gamescope rather than replacing it — your own Gaming Mode is untouched — and Punktfunk only uses it for the sessions it starts itself. On Bazzite it rides in the Punktfunk sysext; there's an Arch package that also installs on a Steam Deck, a NixOS option, and a build script for everything else. `punktfunk-host hdr-probe` tells you exactly which pieces are in place. Verified end to end on Bazzite and on SteamOS 3.8.16.
|
||||
|
||||
Opt-in for this release while it soaks: without the knob, or without the extra package, the gamescope path streams SDR exactly as before. The same package also lets gamescope draw the mouse pointer into the stream itself, which removes a full-frame conversion pass the host used to pay every frame just to add a cursor.
|
||||
|
||||
AMD and Intel hosts benefit from this work even outside gamescope: an HDR stream now stays on the faster encode path instead of dropping to a slower one that also lost loss-recovery, and NVIDIA hosts keep an HDR capture on the zero-copy path rather than falling back to the CPU.
|
||||
|
||||
## New: smaller things worth knowing about
|
||||
|
||||
- **Host cards show which system the host runs.** A small mark for Windows, macOS, Steam Deck, Bazzite, Arch, Fedora and the rest, on every client and in the web console — and a plain Tux for a distribution nothing recognizes. On the cards and tiles it takes the place of the host's initial, which never said anything the name beside it didn't already say. Hosts that predate this, or run something we ship no mark for, keep their letter, so a mixed row still reads as one set.
|
||||
- **Name your host whatever you like.** `PUNKTFUNK_HOST_NAME=Living Room` renames it everywhere a human sees it, in Punktfunk's clients and in Moonlight, without renaming the machine. Spaces and accents are fine.
|
||||
- **Every connect introduces the device by name.** An access request now arrives as *"MacBook Pro wants to connect"* rather than as a fingerprint fragment, and approving one no longer saves that placeholder forever.
|
||||
- **The Windows tray fits Windows 11** — dark menu, crisp at any scaling, icons — and pops a notification naming the device and mode when a stream starts.
|
||||
- **The Windows console can list the machine's real monitors.** It previously showed nothing and explained itself with Linux troubleshooting advice. Note that *streaming* one of them is still Linux-only; the picker now says so plainly instead of saving a setting that did nothing.
|
||||
- **Full chroma (4:4:4)** is now a switch on the Linux and Windows clients, not just on Apple — it's what makes small text and thin lines crisp, so it's a good thing to turn on in a "Work" profile.
|
||||
- **A frame limiter for the game, not for the stream.** `PUNKTFUNK_MAX_FPS` caps how fast the game renders while the stream keeps its full rate, so the GPU time goes to capture and encode instead — and on a laptop or handheld, to less heat and more battery.
|
||||
- **A smoother virtual display.** `PUNKTFUNK_VDISPLAY_HZ_MULT=2` runs the virtual display at twice the session's rate without putting one extra frame on the wire, which halves the worst-case wait for a freshly finished frame. Opt-in, since it costs the compositor the extra work.
|
||||
- **Apple's About page is worth opening**, and the host grid can be sorted and grouped — by name, date added or last connected, and grouped by profile or status.
|
||||
- **Every download now ships a checksum** next to it on the release page, so `sha256sum -c` is all it takes to verify one.
|
||||
- **The stream's on-screen stats scale with your display**, instead of rendering at half size on a HiDPI laptop.
|
||||
- **The Linux app finally has its own icon** in the launcher, taskbar and window switcher on deb, rpm, Arch and nix installs — they had all been shipping a generic monitor glyph.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Android's settings read like every other client's.** Same categories, same sub-sections, one-line explanations instead of desktop paragraphs, and an About page that names the app and its version.
|
||||
- **The Windows app's shell got a round of real polish**: proper nested menus on host tiles, one native control for the profile switcher instead of three glued together, sheets that close on Escape or a click outside, and a host editor that is a centred sheet rather than a tile whose controls could end up below the fold.
|
||||
- **The Linux client's settings rows behave.** A button that appears when you change something no longer slides the control you just clicked out from under the pointer, long explanations stop squeezing the value next to them, and undoing one override changes that one row in place instead of closing and reopening the whole dialog.
|
||||
- **Host cards in a row are the same height again** on Android, whether or not they carry a profile chip or a long trust label.
|
||||
- **Unsaved display settings in the web console are visible and recoverable.** The Custom block's Save button sat below the fold, so people edited, navigated away and lost the lot. There's now a badge in the card header, a highlight on the block, and a sticky bar that stays with you — plus a warning if you try to leave or overwrite pending edits.
|
||||
- **The web console counts paired devices correctly.** Punktfunk's own clients pair on a different plane than Moonlight, and only Moonlight's was being counted — so a perfectly normal setup showed "0 paired".
|
||||
- **The Steam Deck plugin works with a natively installed client**, not only the flatpak. Pairing, the library and launching all failed on a Deck whose client came from a sysext, a package or a nix profile.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Bazzite hosts couldn't update at all.** Every install on the stable channel had started refusing the update feed, correctly — the publisher had been signing a redirect page instead of the actual file list, and had also been quietly dropping older entries from that list for months. Both are fixed and the live feeds are repaired.
|
||||
- **Android: the picture was stretched whenever the stream didn't match the screen's shape.** Streaming a 16:10 desktop to a 20:9 phone, or anything to a tablet in the wrong orientation, filled the panel and distorted everything in it. The video is now sized to the stream's own proportions, centred, with black bars for the remainder — and touch, multi-touch and pen input land on the picture rather than on the panel, so a tap goes where you aimed it. One deliberate consequence: a trackpad swipe that *starts* on a black bar no longer registers, because input landing on the picture is the rule that has to win.
|
||||
- **Android: menus were laid out wrong after every stream.** Content shoved to one side, rows sliding under the status bar. Coming back from a stream that ended while the app was in the background left the app using the stream's full-screen measurements for the rest of its life.
|
||||
- **Android: a black screen with a perfectly healthy overlay.** A box handed the stream mid-picture never received a full frame to start from, and nothing asked for one. Now it asks. A session that receives no video at all also says so in the log, which is what makes the remaining reports diagnosable.
|
||||
- **Android: a setting changed on the wrong layer.** Switching to a profile and changing a row wrote the change to your defaults instead, and switching back sent the next edit into the profile — which read as "the default settings can't be changed any more".
|
||||
- **Android: opening a link could end the stream it was meant to leave alone.**
|
||||
- **Mouse side buttons and iPad keyboards.** On Android, back/forward were dead on mice that report them the way Bluetooth mice and TV boxes tend to. On iPad, every mouse button past the first two clicked *left* on the host, holding a key deleted exactly one character, and scrolling ignored the system's Natural Scrolling setting.
|
||||
- **The mouse pointer was missing from GNOME streams.** Two separate causes, both found on real hardware: the pointer was never moved onto the screen being streamed, and current GNOME versions never draw a pointer into a virtual stream even when asked to. Punktfunk now draws it itself for those sessions.
|
||||
- **KDE Plasma: asking for the streamed display to be primary did nothing.** The desktop stayed on the physical monitor while the log claimed success. Current Plasma ignores the request Punktfunk was making; it now sets display order the way Plasma's own tools do, and reads the answer back instead of echoing its own request.
|
||||
- **A mirrored monitor streamed soft and stuttery.** Mirroring a 4K panel to a client asking for 1080p encoded four times the pixels at the 1080p bitrate. An Automatic bitrate now follows the pixels actually being encoded.
|
||||
- **Pinning a monitor to stream broke Steam Gaming Mode on the same box.** The pin is host-wide, so booting into a Game Mode session with no monitors to mirror made the host refuse to stream at all instead of streaming normally.
|
||||
- **Streams looked washed out on some TVs.** Three encode paths shipped video with no colour information at all. Punktfunk's own clients guess right, so this went unnoticed; TV decoders guess from resolution, and an LG webOS panel read a 4K SDR stream as wide-gamut.
|
||||
- **NVIDIA hosts offered codecs their GPU can't encode.** An older card advertised HEVC, and a client that believed it got about fifteen seconds of blank video and a disconnect. Both platforms now ask the driver what it actually supports.
|
||||
- **The Linux client demanded a sound-server replacement it never used.** On Arch it was a hard dependency that conflicts with PulseAudio, so anyone running real PulseAudio could not install the client at all; the deb and rpm proposed the same swap more politely. Neither the client nor the host speaks that protocol — only games do, and real PulseAudio serves them fine.
|
||||
- **The tray crashed at every launch on Debian and Ubuntu.** A build-time detail, fixed where four other packagings already had it right.
|
||||
- **Streaming on sway or Hyprland destroyed your desktop-portal configuration** — the whole file, on first connect, silently. Punktfunk now changes the one line it needs and leaves everything else byte-for-byte, with a one-time backup.
|
||||
- **Windows: several ways to end a session left the desk dark.** Recovery paths that turn your panels back on were gated behind the wrong condition, so a failed step meant nothing ever turned them back on. Related: Punktfunk's own virtual display was being counted among your real monitors, which disabled the very last-resort "never leave the desk dark" backstop.
|
||||
- **Windows: a mid-stream resolution change could leave a phantom monitor behind**, or silently not change the refresh rate it reported changing.
|
||||
- **Windows: a low-privilege local program could hijack a virtual controller's input channel** and forge input into your desktop. The host now asks Windows itself which process is serving the device instead of trusting a value any local program could write. This is why the host and drivers must now match.
|
||||
- **Deleting a profile crashed the Windows app**, and pinning appeared to do nothing because the switch was below the fold.
|
||||
- **Linux: the About dialog silently dropped its third-party licence notices**, printing 16,000 lines of errors instead.
|
||||
- **Apple: the licence list stranded you with no way back on iPad**, the app icon drew with square corners, profile colours never appeared in menus, and the app described itself as "free software" on a page you reach after paying for it.
|
||||
- **`nix build .#punktfunk-web` had been broken** since a lockfile refresh, and needed a manual hash round-trip on a Linux machine to fix. It no longer has a hash that can go stale.
|
||||
- **Fedora and Windows releases can no longer ship unsigned.** Both had a silent fallback: a rotated or missing signing key would have published packages that every user's updater rejects, or a release signed with a throwaway certificate nobody can pin. On a release tag both now fail the build instead.
|
||||
- **Steam Gaming Mode sessions now prove Punktfunk's settings reached them.** A session that ignored them produced a stream that was correct in every respect except that it had no mouse pointer, and nothing in the logs said so.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- **One protocol number moves.** Streaming protocol stays at **2**, the embeddable C ABI at **13**, and the Windows virtual-display driver protocol at **6** — 0.18–0.22 hosts and clients keep mixing freely. The Windows **virtual-gamepad channel protocol goes 2 → 3** and fails closed in both directions by design, with the existing "update host + drivers together" diagnostic; the installer ships both, so only a hand-assembled pair can mismatch. `pf-dualsense` is renamed `pf-gamepad` (package identity only — crate, INF/CAT/DLL, UMDF service, log file, env var); the four hardware IDs, the bootstrap mailbox name and `PAD_MAGIC` are wire contract and unchanged, and `driver install --gamepad` retires the pre-rename store package by matching `pf_dualsense.dll` rather than the hardware ids.
|
||||
- **The gamepad channel's trust root moved from a mailbox to the device stack.** The host duplicated each pad's shared DATA section into the driver's `WUDFHost` using a `driver_pid` read from a LocalService-writable bootstrap mailbox, gated only on the target's image being `%SystemRoot%\System32\WUDFHost.exe` — which is world-executable, so a LocalService principal (notably the de-privileged plugin runner) could spawn its own suspended `WUDFHost`, publish that pid and be handed `SECTION_MAP_READ|WRITE` on a live section: forged HID input into the interactive desktop, and for `pf-mouse` a real absolute pointer. The pid now comes from the devnode the host `SwDeviceCreate`'d, looked up by the instance id PnP handed back, so the kernel does the routing. Three transports, because `HidD_GetIndexedString` is not forwarded to a UMDF HID minidriver at all and a private device interface cannot be opened on a devnode hidclass owns `IRP_MJ_CREATE` for: a private IOCTL for `pf-xusb`, the HID serial string for `pf-mouse`, and HID feature report `0x85` for `pf-gamepad` (no report-descriptor change — the captured descriptors already declared it).
|
||||
- **Profiles are a sparse override overlay, resolved once.** `pf-client-core::profiles` holds `SettingsOverlay` (sparse `Option`s) and a catalog in its own `client-profiles.json` — deliberately not the settings file, which has five whole-file load-modify-save writers with no merge. Resolution has one implementation, `trust::effective_settings()`: `overlay(profile).apply(global)` where `profile = one-off ?? host binding ?? none`. `absorb` serves per-control shells (compare the effective settings before and after one control fired; the comparison is against what the control was *showing*, so a value equal to today's global still records a pin) and `clear` is the only removal, keyed by the overlay's own field names with `resolution` aliasing the width/height/match-window tri-state. `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`; `upsert` now preserves user-set state against refreshes that carry none of it; all three client stores write temp+rename. Apple mirrors the model field-for-field with unknown-key carry-through, appending `profileID`/`pinnedProfileIDs`/`osChain`/`addedAt` last because that JSON is a frozen app↔widget contract; Android re-keys its host store from `addr:port` to a minted UUID in one migration pass tested against a verbatim pre-migration blob.
|
||||
- **`punktfunk://` is one grammar with one vector file.** `punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]`, with a 2 KB cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is a refusal, not a U+FFFD), control characters refused after decoding, and `fp=` held to 64 hex. `pair` parses and is refused so a link can never start a trust ceremony; `pf://` parses as an input alias and is never emitted or registered. Resolution is stable id → unique host name → `addr[:port]`, with `host=`+`fp=` as the reinstall recovery path. `clients/shared/deeplink-vectors.json` is the cross-language contract — 44 cases including every refusal code — run by the Rust, Swift and Kotlin suites from the source tree, so three parsers cannot drift into three security postures.
|
||||
- **A brain layer now sits under the front-ends.** `ConnectPlan` is a resolved intent with one constructor per door (card click, CLI verb, URL); `ConnectPlan::resolve` is pure, which is what lets the URL router be tested without a config directory. `plan_from_link` holds the deep-link security rules once instead of per shell. `WakeWait` is Apple's `HostWaker` cadence as a pure step function (packet at 0 s and every 6 s, presence polled every second, 90 s budget, then a park rather than an error). Session spawn, its argv and its stdout contract moved here too. `punktfunk-session --resolved-spec <path>` is a new spec mode in which the renderer performs **zero** store reads — it had been re-deriving effective settings, the clipboard decision and the profile name inside the thing that draws pixels — and the match-window write-back is now reported on stdout for the spawner to persist rather than being a sixth concurrent writer of the settings file. `punktfunk-session --pair` prints a deprecation notice and forwards; Decky's `--list-hosts`/`--reachable` flags remain a frozen compat contract.
|
||||
- **The `os=` advert is additive on two carriers.** The host detects its OS once per process and emits an icon-friendly specificity chain, generic → specific: `windows`, `macos`, `linux[/<family>][/<id>]` (e.g. `linux/fedora/bazzite`, `linux/arch/steamos`), the middle token being the first recognized `ID_LIKE` ancestor and the leaf `ID` verbatim, sanitized to TXT-safe `[a-z0-9._-]`. Clients walk it most-specific-first, so an unknown distro degrades to its family's mark and finally to Tux with zero distro→parent knowledge on the client. Carried by a new advisory mDNS `os=` TXT key (same trust posture as `mac`) and by `HostInfo.os` + `HostInfo.os_name` on the mgmt API; GameStream serverinfo and the QUIC Welcome are untouched. Android's JNI discovery record appends `os` as its eighth `␟`-field, append-only and pinned by tests in both directions. `assets/os-icons/` holds the ten master SVGs each platform derives from (Font Awesome Free brands CC BY 4.0 + Simple Icons CC0, folded into `THIRD-PARTY-NOTICES.txt`).
|
||||
- **Vulkan Video learned 10-bit, and stopped guessing what it can do.** An HDR session opens a Main10 profile with 10-bit component depths, a `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16` picture + DPB, and an SPS carrying `bit_depth_*_minus8 = 2` with the BT.2020/PQ CICP triplet; AV1 carries `high_bitdepth` plus the matching sequence-header OBU bits, which sit *before* the CICP bytes, so getting them wrong puts every following field one bit out of phase (the new test reads the packed bits back). `rgb2yuv10.comp` is a pure BT.2020 NCL 3×3 matrix — the samples arrive already PQ-encoded, so there is no transfer function to apply and applying one would be wrong — writing into the high bits of `R16`/`RG16` scratch planes merely size-compatible with the picture's. `probe_encode_support` became `VulkanEncodeCaps { supported, eight_bit, ten_bit }`, answered by `vkGetPhysicalDeviceVideoCapabilitiesKHR` against the very profile chain the session open builds, so an incapable device routes to VAAPI *before* burning a failed open; `can_encode_10bit` now reports the union of VAAPI's and Vulkan Video's answers rather than VAAPI's alone.
|
||||
- **NVIDIA gained a zero-copy HDR leg and an honest codec advertisement.** A packed 10-bit frame travels LINEAR dmabuf → Vulkan bridge → CUDA → NVENC `ARGB10`/`ABGR10`, letting NVENC do the BT.2020 conversion itself: no host CSC pass, no depth loss. HDR never routes through the tiled EGL de-tile blit (it renders into an 8-bit `GL_RGBA8` texture), and a host without the direct-SDK backend keeps the CPU path, since libav's HDR route swscales into a P010 hardware frame a packed-10-bit CUDA buffer cannot fill. Separately, both platforms now probe `nvEncGetEncodeGUIDs` on one throwaway direct-SDK session instead of advertising a static H.264|HEVC|AV1 superset, wired into `host_wire_caps` *and* the GameStream serverinfo mask; it fails open, so it can only ever narrow. The HEVC 4:4:4 answer rides the same session rather than a libav `hevc_nvenc` FREXT probe — that open is the prime suspect for the field bug where one probe wedges NVENC process-wide with `NV_ENC_ERR_INVALID_VERSION`.
|
||||
- **Four encode paths were emitting no colour description.** Vulkan Video HEVC built an SPS with no VUI at all (the default backend for AMD/Intel Linux hosts), Vulkan AV1 packed `color_description_present_flag = 0`, openh264 wrote nothing, and the libav-NVENC Linux path excluded packed-RGB 4:2:0 on the belief that NVENC writes its own VUI (libavcodec derives it from the `AVCodecContext` colour fields). All four now signal BT.709 limited, which is what every host CSC actually produces. Two tests parse the real emitted bitstream rather than re-asserting constants. GameStream's `ServerCodecModeSupport` gains `SCM_AV1_MAIN10`, each 10-bit bit gated on that codec's own `can_encode_10bit` **and** the SDR baseline already advertising it; `host_hdr_capable` became codec-agnostic and the RTSP honor degrades a session whose *negotiated* codec can't carry 10 bits.
|
||||
- **D3D11 multithread protection is now enabled before libav sees the device.** libav turns it on in `d3d11va_device_create`; we take `d3d11va_device_init` because we hand it the capturer's existing `ID3D11Device`, and nothing enabled it on our side. Two consequences, one shipping for a long time: `av_hwdevice_ctx_create_derived(QSV ← D3D11VA)` was rejected by MFX as `MFX_ERR_UNDEFINED_BEHAVIOR (-16)` (reported as the uninformative "Error setting child device handle"), and AMF — the default Windows zero-copy path — was running with libav's `d3d11va_default_lock`, whose `ID3D11Multithread::Enter`/`Leave` are documented no-ops while protection is off, so the lock serialising our capture thread against its encode thread had never serialised anything. Measured on Intel UHD 750: protection is the only variable that matters, and it read back `was=false` every time. QSV still defaults off.
|
||||
- **gamescope carries three patches and a monotonic marker.** The PipeWire node additionally offers `xRGB_210LE`/`xBGR_210LE` with mandatory SMPTE ST.2084 + BT.2020 props (spelled out numerically — PipeWire 1.4.11 on Fedora 43 has no `SPA_VIDEO_TRANSFER_SMPTE2084`), `paint_pipewire()` composites into them with the HDR screenshot LUT and `EOTF_PQ`, and `--pipewire-composite-cursor` paints the pointer with the same `MouseCursor::paint` the scanout composite uses. New formats are listed last, so every existing consumer keeps negotiating the 8-bit stream bit-for-bit. The `--version` banner stamps `+pfhdr<N>` as a **patch level**, because punktfunk fixes a session's bit depth in the Welcome before the display exists and PQ frames on an 8-bit encoder are a deliberate hard error — so the capability answer must be a static property of the binary that will be spawned, never an optimistic negotiation. Level 1 = HDR formats, level 2 = the cursor flag; `cursor_blend_for` and the `gamescope_cursor` resolver consult it through one helper because the reader without the blend is a wasted X11 connection and the blend without the reader is a stream with no pointer. The build forces `force_fallback_for=libliftoff,vkroots,wlroots` — Fedora 44's `builddep` pulls in `wlroots-devel`, and meson then links it shared, producing a binary that starts only inside the build container. For the same reason the **C++ runtime is linked statically**: the Arch container builds against gcc 16.1.1 while SteamOS 3.8.16 ships libstdc++ 3.4.34, so the pacman package died at `--version` with `GLIBCXX_3.4.35 not found` on the gamescope backend's most important platform. It is safe here because gamescope links no shared C++ library at all — its `NEEDED` list is all C, and glslang/SPIRV are build-time only — so no C++ ABI crosses a shared boundary; the cost is ~1 MB. The flags are appended to `LDFLAGS` rather than passed as `-Dcpp_link_args`, which would replace meson's environment-derived value and silently drop makepkg's `-z relro`/`-z now`/`--as-needed`, and the build now asserts no `libstdc++` in `NEEDED`, since a static runtime is otherwise invisible in a passing build and surfaces only as a binary that will not start somewhere else. Both managed spawn modes (`gamescope-session-plus` via `GAMESCOPE_BIN`+`PF_HDR_ARGS`, SteamOS via a PATH shim) now read the running compositor's `/proc/<pid>/cmdline` once its node appears and refuse the session on a missing flag, latching the capability off for the process so the retry converges on a correct SDR host-composited session; it fails open at every ambiguity.
|
||||
- **The cursor decision is now source- and session-scoped.** `capturer_supports_hdr_for(compositor)` replaced the flat `false`; the HDR-negotiation-failure latch became per-source (a wedged monitor mirror no longer disables a gamescope session's HDR, or vice versa); the keep-alive reuse key gained `hdr`, since gamescope cannot turn HDR on live. `cursor_blend_for` grew a **no-channel** arm: the capture-latched console client never advertises `CLIENT_CAP_CURSOR`, so its session asked Mutter to *embed* the pointer — a fiction since Mutter 48 removed hw-cursor inhibition, where the software overlay is suppressed stage-globally whenever any physical head realizes a HW cursor, and cursor-only motion schedules no re-record (mutter#4939). Probed on Mutter 50.3: embedded + relative motion froze the frame counter while `SPA_META_Cursor` positions kept flowing. Those sessions now take cursor-as-metadata + host composite permanently; embedded survives only as the can't-blend fallback. The stream loop also parks the seat pointer at the streamed surface's centre through the session's own input pipeline, retried on a schedule because the first park can land on a still-cold EIS connection — a pointer-lock client sends only relative deltas, so nothing else ever moves the pointer into a freshly created virtual output.
|
||||
- **libei absolute-coordinate resolution gained a scale rung.** A display scale *s* shrinks an output's EI region to logical pixels (Mutter advertises 853×533 for a 1280×800 output at 1.5), so the exact-size rung missed every scaled output and absolute input fell through to `regions.first()`. A new rung between exact and first requires one consistent factor (1..=4, fractional included) to map region onto mode on both axes, with per-axis rounding slack.
|
||||
- **`GET /display/monitors` answers on Windows.** `monitors::list` was a per-compositor dispatch whose non-Linux arm bailed, and `detect()` wasn't `cfg`-gated, so a Windows host returned an empty list plus a verbatim Linux error string about `PUNKTFUNK_COMPOSITOR`. `target_inventory()` already walked the CCD database and now reports the geometry it had in hand. Two fields are reported honestly rather than invented: `scale` is always 1.0 (Windows scaling is per-application per-monitor DPI, not a compositor-global logical scale, so the geometry is pixels) and an inactive head gets zeroed geometry, because CCD mode indices are only valid for active paths. `MonitorsResponse.pin_supported` is a new **capability** reported by the build that would have to honor a pin — per-monitor capture is Linux/portal only, since `pf-capture`'s sole Windows entry point is `open_idd_push` from our own IddCx display and DXGI Desktop Duplication was deliberately removed — and a non-Linux `capture_monitor` in the whole-object PUT is coerced away with a log line rather than 400'd, so a stored pin self-heals instead of failing every later settings save. It defaults to `true` when absent, so an older host isn't retroactively locked out.
|
||||
- **The `pf-vdisplay` sweep landed its contained half.** A gamescope AB/BA lock inversion between a connect and the restore worker; `observe_session_instance` holding `LAST_INSTANCE` across `invalidate_backend` and a 10 s `systemctl` shell-out; `admit` holding the live-session table across budget checks that block on the manager `state` lock (itself held across DDC round trips and 3 s activation ladders); GameStream never registering its display, so both Windows budgets were blind to it; and `ensure_exclusive_watch` panicking on a failed thread spawn while holding both `exclusive_watch` and `state`, poisoning the two locks the manager runs on. Mutter's `create` timeout used to drop its stop flag *without setting it*, leaving a thread that had already made the virtual output primary parked forever holding the D-Bus connection that is the monitor's lifetime — under the default topology that orphan applies a sole-monitor `APPLY_TEMPORARY` config Mutter only reverts once the virtual monitor disappears, with no in-process recovery. The gamescope sub-mode travels as a `GamescopeRoute` return value carried on the backend instance instead of being published into `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` and read back with the lock released in between; `ENV_LOCK` now covers detection's readers too, sampled into one `EnvProbe` (glibc `setenv` can realloc `environ` and free the old string, and the session watcher reads those five keys every second). Helper processes are enrolled in a Job object, since `Child::kill` is one `TerminateProcess` and every Windows helper is reached through a shell — the hanging process is a grandchild, which is why a green `cargo test -p pf-vdisplay` still failed its CI job by orphaning a 60-second `ping.exe` that held the build step's stdout pipe.
|
||||
- **`query_active_config` treats zero active paths as an answer.** Windows rejects a zero-count `QueryDisplayConfig` rather than returning an empty set, so "every panel off, a KVM switched away, a headless box between adapter and first monitor" came back as "the query failed" — which is exactly the teardown-gate condition whose recovery legs exist to stop the operator's panels being left dark. Measured on an RTX 4090 / Win11 26200 with the TV powered off: `numPaths = 0`, then `0x57 ERROR_INVALID_PARAMETER` from a console session (`0x5 ERROR_ACCESS_DENIED` from session 0). Related: targets carrying our own EDID manufacturer id (`PNK`, matched on the monitor **device path** in both `#` and `\` spellings) are now classified `external_physical = false`, so `restore_displays_ccd`'s last-resort `force_extend_topology` can actually fire — it never could, because the restore runs before the virtual is REMOVEd and our own display kept `lit >= 1`.
|
||||
- **KWin's `set_primary_output` handler is literally `// intentionally ignored`.** Output order is driven by per-output `set_priority` (management ≥ 3; we bind up to v22 and never called it) — exclusive topology only ever *looked* right because disabling every other output leaves KWin nothing else to promote. Ours now takes priority 1 with every other enabled output renumbered uniquely behind it, and `primary_taken` (which echoed the request) became `primary_verified`, read back after one sync barrier.
|
||||
- **The unsafe-proof program finished.** `clippy::undocumented_unsafe_blocks` cannot see an unsafe operation sitting directly in an `unsafe fn` body, so `unsafe_op_in_unsafe_fn` is denied workspace-wide and the three previously exempt crates (`punktfunk-core` 167 items, `pf-presenter` 123, `pf-client-core` 91) now deny both — with the recurring shapes stated once per crate (the C ABI contract in `abi.rs`, the Vulkan contract in `pf-presenter`'s `lib.rs`, split into CREATE/RECORD/DESTROY because only DESTROY carries a real precondition) rather than 141 restatements of a signature. Eleven hand-written `#[repr(C)]` mirrors of external C structs — the sharpest remaining memory-safety risk, since a wrong field offset compiles and doesn't reliably crash — are now `const _: () = assert!(..)` layout-checked on every build: `AVCUDADeviceContext`, the `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` pair (duplicated verbatim in two crates that can't depend on each other), the six cuda.h structs, Darwin's `msghdr_x`, and `IPolicyConfigVtbl`, which mirrors an undocumented COM interface called by slot index through a ten-entry unnamed `_reserved` gap. ⚠️ A Linux-only survey of a cross-platform crate undercounts by whatever `cfg` hides — here by 3× — so every crate had to be re-verified on Windows after being "done".
|
||||
- **Supply chain and release integrity.** Per-release SBOM, full-tree `cargo`/`bun` audits and a real license gate; every release asset now gets a `<asset>.sha256` sidecar written in `upsert_asset` (sidecars rather than one `SHA256SUMS`, because eight workflows attach to the same release object concurrently and a shared manifest would be a read-modify-write race — and the PowerShell twin writes LF with no BOM, since GNU `sha256sum` folds a trailing CR into the filename). The Bazzite sysext feed carries `SHA256SUMS.asc`, a detached OpenPGP signature verified against a **baked-in** `packages@unom.io` key (`AF245C506F4E4763`, the same key that signs the RPMs) — a key fetched from the feed you're authenticating authenticates nothing — with `PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1` as the informed way through and a `--seal` mode that re-signs an existing manifest without rebuilding an image. The publisher's bug is worth recording: the registry answers a file GET with a 303 to presigned storage and `curl -f` does **not** treat 3xx as an error, so two un-`-L`'d reads "succeeded" holding `<a href="…">See Other</a>.` — `--seal` signed that HTML page (whose presigned URL expires in 300 s, so the `.asc` covered bytes that exist nowhere), and the publish merge kept it while `grep -v` dropped every prior image line. Both reads now go through one `read_manifest` that follows redirects and keeps only well-formed `<sha256> <filename>` lines. The Windows drivers gained one stable publisher identity (thumbprint `4B8493E7CD565758D335F8F4F05C5A7261A13E02`, RSA 3072, valid to 2036) delivered via `DRIVER_CERT_PFX_B64`; `driver install` purges stale `CN=punktfunk-driver` certs before adding and `driver uninstall` removes them entirely, closing the leak where every upgrade added two more self-signed machine roots that nothing ever removed. The decoded `.pfx` is now deleted after the last signing step and from a script-scope trap. Fail-closed guards on `refs/tags/v*` cover the MSIX cert, the host installer cert and the RPM GPG key. This does **not** authenticate the driver download — a self-signed leaf is its own root, so the installer must trust it for PnP to proceed; attestation signing remains the real fix.
|
||||
- **New knobs.** `PUNKTFUNK_HOST_NAME` overrides the GameStream serverinfo `<hostname>` element and the mDNS service instance name; `dns_label()` sanitizes the A-record target separately and passes an already-legal name through byte-for-byte, because `mdns-sd` rejects the whole `ServiceInfo` on an illegal target (which would take discovery down rather than merely look wrong), and the display name loses `.` since clients derive the name from the first label. `PUNKTFUNK_MAX_FPS` becomes gamescope's `--nested-refresh` on all three sessions we own, landing on `PF_HZ` alone and not `CUSTOM_REFRESH_RATES` so the advertised mode stays the client's; the attach path has no lever and is untouched. `PUNKTFUNK_VDISPLAY_HZ_MULT` multiplies the display's rate while the pacing rate becomes the session's rate floored by the achieved one — the same value as before whenever the knob is unset. `PUNKTFUNK_OSD_SCALE` scales the stream chrome, which now reads SDL's window display scale per frame and quantizes it into the damage key, re-deriving the typeface at the scaled size rather than transforming the canvas.
|
||||
- **Capture-stall attribution v1.** The driver's shared ring header grows a v2 telemetry tail (drain-loop heartbeat QPC, last-acquire QPC, full-width offered counter) that the host samples between fresh frames to attribute each stall as worker-stalled / compose-silence / delivery-leg, version-safe in both directions (gated on the host-stamped header version; a zero heartbeat reads as a pre-telemetry driver). A refcounted micro-probe engine (per-adapter fence round-trip, `DwmGetCompositionTimingInfo` tick, watchdogged `DwmFlush`, `D3DKMTGetScanLine`, a CPU jitter sentinel) samples on detached threads, and an event-id-filtered real-time ETW session on `Microsoft-Windows-DxgKrnl` rides every stall line as a DDI bracket summary. Degrades to `etw=unavailable` without admin; absence is stated, never guessed. Also: a capture's meta now records the encoder and GPU that produced it, read once per capture from `pf_gpu::active()`.
|
||||
- **CI restructure.** The five builder images moved to a LAN registry under content keys (a hash of the `ci/` tree), built only when the key has no manifest, and releases pin them by copying the key manifest to a `vX.Y.Z` tag via the registry API — no rebuild, no bytes moved. sccache backs every Rust job through one S3 bucket with `CARGO_INCREMENTAL=0`; Android and Arch got baked images (Android was downloading ~3 GB of SDK/NDK per run, Arch ~1 GB of pacman); Apple got sccache plus a pinned `DerivedData` root. Path filters stop docs-only pushes lighting up the whole fleet, concurrency groups let a newer push supersede a queued run, and the report-only bench job moved to its own nightly workflow. The nix flake's two bun fixed-output derivations were replaced with bun2nix 2.1.2, so there is no aggregate hash left to go stale.
|
||||
- **Two packaging traps recorded.** `scripts/ci/gitea-release.sh` is sourced under POSIX `sh` (dash) on the deb and decky attach steps, so no bash-isms; and the tray must be built in its **own** cargo invocation, because cargo feature unification hands it a tokio-flavoured `zbus` with no tokio runtime anywhere near it when co-built with the host.
|
||||
@@ -0,0 +1,20 @@
|
||||
Wire-compatible with 0.21.x and 0.22.0 — hosts are untouched by this release, and everything already paired keeps working.
|
||||
|
||||
**If you installed 0.22.0 on Windows or Linux, update now:** this fixes connecting, which 0.22.0 broke on exactly those clients. Coming from 0.21.0, you get everything 0.22.0 added — settings profiles, `punktfunk://` links, the `punktfunk` command, Gaming-Mode HDR — plus these fixes.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Connecting works again on Windows and Linux.** 0.22.0 accidentally shipped the wrong program as the piece that renders your stream. On Windows, every connect immediately bounced you back to the host list with no message; on Linux, the stream window simply never appeared. Mac, iPhone, iPad, Apple TV and Android were not affected. Nothing about how streams work has changed — 0.22.1 delivers what 0.22.0's notes promised.
|
||||
- **A stream that can't start now tells you why.** If the stream window exits without reporting anything, the Windows client now shows its exit code instead of silently returning to your hosts — a broken install is a message, not a mystery.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Every `punktfunk` command now explains itself.** `punktfunk help launch` — or `--help` after any command — prints that command's flags, what lands where, and what each exit code means, so a script author never has to read the source. `punktfunk help` still lists everything.
|
||||
- **`punktfunk reachable` stopped scolding.** Probing an address you haven't saved is exactly what the command is for; it no longer suggests pairing first before answering.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- The 0.22.0 breakage: a stray copy of the GTK shell's sources landed in the session crate and overwrote its `main.rs` — the `[[bin]]` entry point — so `punktfunk-session` was built from the wrong file. On Windows that file's non-Linux arm is a three-line stub (exit 2, no stdout contract); on Linux it was the shell itself, whose `--connect` handling execs `punktfunk-session` — that is, itself. Every compile gate stayed green, because the wrong program compiled perfectly.
|
||||
- CI now *runs* what it ships: an integration test spawns the built `punktfunk-session` against a refusing port and fails unless a stdout-contract line answers (proven against the 0.22.0 stub, which fails it); another runs `punktfunk` over its help surface. `punktfunk-cli` also joined the Windows build/clippy/fmt/test gates — it was packaged in the MSIX but only the release workflow ever compiled it.
|
||||
- The Windows shell's `SpawnEvent::Exited` now carries the child's exit code; a nonzero exit with no contract line gets a banner naming it. Codes 0 (window closed) and −1 (our own kill) stay silent as before.
|
||||
- No wire, ABI or driver changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0.
|
||||
@@ -0,0 +1,22 @@
|
||||
Wire-compatible with 0.21.x and 0.22.x — nothing about streaming changed, and everything already paired keeps working. This release only touches Windows PCs you stream *to*; the apps on your phone, tablet, Mac and TV are unchanged, as are Linux hosts.
|
||||
|
||||
**If you stream to a Windows PC and use a controller, update that PC.** On 0.22.0 and 0.22.1 your controller worked everywhere in the app but no game on the PC ever saw it. Update using the Windows installer rather than replacing the program by hand — the repair includes the controller drivers themselves.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Controllers work again on Windows.** Your controller paired, the app responded to it, and a DualSense's touchpad could even still move the mouse pointer on the PC — but games saw no controller at all. Windows had been attaching one of its own built-in drivers to Punktfunk's virtual controller instead of Punktfunk's, and that driver cannot run on a controller that isn't physically plugged in, so the controller was created and then never started. Nothing you could change in the app worked around it.
|
||||
|
||||
This hit almost everybody, because it hit the controller type Punktfunk emulates by default. If you had gone into settings and explicitly chosen DualShock 4, Xbox 360, DualSense Edge or Steam Deck, yours kept working the whole time — only the default was broken. Both 0.22.0 and 0.22.1 are affected; 0.21.0 and earlier are not.
|
||||
|
||||
## Improved
|
||||
|
||||
- **Punktfunk is spelled Punktfunk on Windows.** The virtual display, controllers, mouse and firewall entries Punktfunk creates all announced themselves in lower case. They now carry the proper name — in Device Manager, in your monitor list, and in Windows Firewall. Purely cosmetic, and it appears once the updated drivers install.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- The controller regression was a one-line hardware-id slip. `560e663a` renamed the driver *package* `pf-dualsense` → `pf-gamepad` and its message asserted the four hardware ids were untouched — but `WinDsIdentity::dualsense()` was renamed along with it, so the host began advertising `pf_gamepad`, which `pf_gamepad.inx` does not declare (it still binds `root\pf_dualsense` / `pf_dualsense` / `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck` — deliberately, as the contract with already-installed systems). PnP matched none of our models, fell through to the USB ids the same devnode synthesizes for the DualSense identity (`USB\VID_054C&PID_0CE6`, `USB\Class_03`), and Microsoft's inbox `input.inf` won on signature. `HidUsb` then bound a software-enumerated devnode with no USB port behind it and could not start — `CM_PROB_FAILED_START`. Without a start, hidclass never enumerates the collection PDO, so there is no device interface, so the devnode cannot answer a channel proof, and the gamepad channel's v3 delivery gate correctly refused to hand over the shared input section. Every layer downstream behaved exactly as designed.
|
||||
- Measured against a clean install, all four identities served by the one `pf_gamepad.inf`: DualSense (`pf_gamepad`) → `input.inf`/`HidUsb`, failed start; DualShock 4 (`pf_dualshock4`), Edge (`pf_dualsenseedge`) and the virtual mouse (`pf_mouse`) → our package, attached. `devgen /add /hardwareid "root\pf_dualsense"` binds our INF with no problem code.
|
||||
- A new `hwid_matches_inf` test parses `pf_gamepad.inx`'s `[Models]` section and asserts every hardware id the host puts on a pad devnode is declared there, with a vacuity check on the parse so a shape change fails loudly rather than passing empty. `DS4_HWID` / `DECK_HWID` exist so the test pins the same constants the create paths use.
|
||||
- Why the installer and not a binary swap: re-creating a software device with a known instance id revives the existing devnode with its previously-bound driver and never re-ranks against the driver store. Instance ids did not change, so a PC still holding a stale virtual pad bound to `input.inf` would revive `input.inf` even with the correct hardware id. `driver install --gamepad` sweeps those devnodes; replacing the host executable alone does not.
|
||||
- The rename covers the four driver INFs' `[Strings]`, every `SwDeviceProfile` description, `pf-mouse`'s HID manufacturer and product strings, pf-vdisplay's IddCx endpoint names, the EDID `0xFC` display-name descriptor (one byte — `Edid::generate_with` recomputes both block checksums), and the netsh rule names. Left in lower case on purpose, because they are identities rather than display names: the `SwDeviceCreate` enumerator (it *is* the `SWD\PUNKTFUNK\…` instance-id path), `%ProgramData%\punktfunk`, the `CN=punktfunk-driver` cert subject, and `install.rs`' already-lowercased device probes. netsh, `Get-NetFirewallRule -DisplayName` and PowerShell's `-match` are case-insensitive, so the delete paths still reap what earlier releases created.
|
||||
- No wire, ABI or driver-protocol changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0 and 0.22.1.
|
||||
@@ -0,0 +1,27 @@
|
||||
Wire-compatible with 0.21.x and 0.22.x — nothing about streaming changed, and everything already paired keeps working. This release is almost entirely about the Windows installer; the apps on your phone, tablet, Mac and TV are unchanged, as are Linux hosts.
|
||||
|
||||
**If you stream to a Windows PC, update that PC.** The 0.22.1 and 0.22.2 installers were built without the web console in them at all — not broken, absent. If the tray menu on your PC says "Open web console (not responding)" and the page never loads no matter what you try, that is this, and reinstalling those versions could never have fixed it.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **The web console is in the installer again.** On 0.22.1 and 0.22.2 the Windows installer shipped with the console missing entirely: the files it runs from were never included, so the PC had nothing to start and nothing to open. The host itself was fine the whole time — streaming, pairing and controllers all worked — but the settings page you reach in a browser simply was not there. The tray reported it as "not responding", which was true and also the only clue you got, and because every reinstall produced the same installer, uninstalling and reinstalling made no difference. Updating to 0.22.3 restores it; you don't need to touch your settings, your password, or your paired devices.
|
||||
|
||||
- **Updating no longer stops on a "DeleteFile failed; code 5" error.** Partway through an update, the installer could stop on a dialog complaining that it could not replace `bun\bun.exe`, offering only Try again, Skip, or Cancel — and Try again usually failed the same way. Windows refuses to replace a program while it is running, and the pieces of Punktfunk that were still running weren't the ones the installer knew how to stop. It now shuts all of them down, waits for them to actually exit rather than assuming, and if something still holds the file it finishes the install and puts the new copy in place on your next restart instead of leaving you stuck at a dialog.
|
||||
|
||||
- **Updating no longer switches your plugins off.** If you had enabled the script and plugin runner, every update quietly disabled it again, so plugins stopped running until you noticed and re-enabled them by hand. Updates now leave it exactly as you had it — on if it was on, and still off by default for everyone who has never turned it on.
|
||||
|
||||
## Improved
|
||||
|
||||
- **You can save or share the host's log from the console.** The Logs page has two new buttons: one downloads what you're looking at as a timestamped `.log` file, the other hands it to your phone or tablet's share sheet, or copies it to the clipboard on a desktop. Handy when someone asks you for a log — the file carries full dates and times, so it still makes sense once it leaves your browser. Whatever level filter and search you have applied is what you get, and it saves everything that matches rather than just the part on screen.
|
||||
|
||||
- **Televisions that struggle with the startup speed test can be told to ease off.** Two seconds into a session Punktfunk briefly bursts traffic to measure how much room your network really has. On some TVs that burst is enough to disturb the very link it is measuring — one LG set showed no video for fourteen seconds when it went badly. The burst's size can now be capped, so a device that already limits its own speed test can keep ours in line with it. Nothing changes unless a device asks for it.
|
||||
|
||||
## Under the hood (for developers)
|
||||
|
||||
- The console's absence was one CI variable in the wrong place. `windows-host.yml` exported `WEB_OUTPUT_DIR` on the last line of "Build + smoke-boot web console (bun)", and `be2fabcf` had just put that step behind `if: steps.webconsole.outputs.cache-hit != 'true'`. So the first build after the cache was populated — and every build after it with `web/**` and `sdk/**` unchanged — skipped the step, left the variable unset, and `pack-host-installer.ps1` reads an unset `WEB_OUTPUT_DIR` as "omit the console", announcing it in a single `Write-Host` before packing happily. Confirmed from the job logs rather than inferred: on both the v0.22.1 (run 14235) and v0.22.2 (run 14272) tag builds, step 12 is `skipped` and the job is green. `BUN_EXE` and `SCRIPTING_BUNDLE` are exported from unconditional steps, which is why those installers still carried bun and the plugin runner but no `{app}\web`.
|
||||
- Downstream, `web setup` bailed at `web launcher missing: {app}\web\web-run.cmd` before registering anything, so there was no `PunktfunkWeb` task, no listener on 47992, and no firewall rule — and Inno ignores `[Run]` exit codes, so the failure left no trace in the install at all. `WEB_OUTPUT_DIR` now comes from its own unconditional step that throws when `web\.output\server\index.mjs` is absent, and a new pre-pack step asserts all five payloads (console, bun, plugin runner, FFmpeg DLLs, VB-CABLE) so a missing input fails the build rather than silently redefining the installer. The shape is borrowed from the packer's existing VB-CABLE check. FFmpeg was the most dangerous of the silent ones: an `amf-qsv` host link-imports avcodec, so an installer without those DLLs ships a host that cannot start at all.
|
||||
- The same cache-hit build is why `bun.exe` locked. It ships under `WithWeb` **or** `WithScripting`, but the installer's pre-copy stop was `#ifdef WithWeb` — so a console-less installer shipped bun while the only code that stopped bun was compiled out. `StopBunRuntimes` replaces `StopWebConsole` behind the same `WithWeb || WithScripting` gate the payload uses, and covers what the old routine structurally could not: neither bun runs under the host service (both are Task Scheduler tasks — `PunktfunkWeb` as SYSTEM, `PunktfunkScripting` as LocalService), and the runner listens on no port, so a task-name-plus-port sweep could never see it. It now disables both tasks before stopping them — they carry restart-on-failure at 10× and 999× a minute, and the web task also has a logon trigger, so a force-kill alone invited a respawn into the middle of a copy that takes over a minute at `lzma2/max` — kills any bun whose image lives under the install dir (by path, so an unrelated bun survives), and polls until they are gone, since `Stop-ScheduledTask` returns on request and `Stop-Process` is `TerminateProcess`. `bun.exe` also gained `restartreplace`, so a survivor defers to reboot instead of dead-ending the install.
|
||||
- Disabling a task is not free: unlike a stopped one it does not return at the next boot, so an aborted install would have taken the console down permanently. Two restores cover it — a last-in-`[Run]` entry for the normal flow and `DeinitializeSetup`, which Inno calls even on user cancel — both re-enabling only what was enabled before the copy. That is also the plugin-runner fix: the scripting `[Run]` entry re-registers its task and then unconditionally disables it, correct on a first install and wrong on every upgrade since.
|
||||
- `PUNKTFUNK_ABR_PROBE_KBPS` sets the startup capacity probe's burst target for embedders. Unset, zero or unparseable keeps the 2 Gbps default, so existing sessions are unchanged; `PUNKTFUNK_ABR_PROBE=0` remains a poor substitute because it pins the climb ceiling at the negotiated ~20 Mbps.
|
||||
- The console's log export serializes the filters' full result rather than the rendered tail — the 1000-row cap is a DOM budget and has no business truncating a file destined for a bug report — and stamps each line with a local ISO 8601 timestamp plus numeric offset. Web Share level 2 is probed at runtime after mount (SSR has no `navigator`, and guessing there would mismatch on hydration), falling back to the clipboard, and the button is omitted only where neither exists.
|
||||
- No wire, ABI or driver-protocol changes: wire protocol 2, C ABI 13, Windows virtual-gamepad channel 3, virtual-display driver protocol 6 — identical to 0.22.0, 0.22.1 and 0.22.2.
|
||||
@@ -173,6 +173,12 @@ package_punktfunk-host() {
|
||||
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
|
||||
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
|
||||
# Install-kind + channel marker, read by the host's update-check surface (planning:
|
||||
# host-update-from-web-console.md §4.1). A canary build's pkgrel is `0.<zero-padded run>`.
|
||||
local _pf_update_channel=stable
|
||||
[[ $pkgrel == 0.* ]] && _pf_update_channel=canary
|
||||
printf 'pacman %s\n' "$_pf_update_channel" | \
|
||||
install -Dm0644 /dev/stdin "$pkgdir/usr/share/punktfunk/install-kind"
|
||||
install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \
|
||||
"$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
|
||||
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
|
||||
# --seal re-sign a feed's EXISTING manifest without publishing an image. For feeds published
|
||||
# before signing existed, and after a key rotation. Idempotent.
|
||||
# before signing existed, and after a key rotation. Idempotent. Also re-publishes the
|
||||
# manifest if normalizing it changed anything, because the signature has to cover the
|
||||
# bytes a client downloads.
|
||||
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name),
|
||||
# RPM_GPG_PRIVATE_KEY (armored private key; absent => unsigned, fatal on a v* tag)
|
||||
set -euo pipefail
|
||||
@@ -40,6 +42,28 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
|
||||
SUMS="$WORK/SHA256SUMS"
|
||||
SIG="$WORK/SHA256SUMS.asc"
|
||||
FETCHED="$WORK/SHA256SUMS.fetched" # exactly what the registry served, before normalization
|
||||
|
||||
# read_manifest -> the feed's current manifest, normalized into $SUMS and kept verbatim in
|
||||
# $FETCHED. Returns non-zero iff the feed has no manifest at all.
|
||||
#
|
||||
# -L is not optional here. The registry answers a file GET with a 303 See Other pointing at
|
||||
# presigned object storage, and `curl -f` does NOT treat a 3xx as an error — so without -L the call
|
||||
# "succeeds" and hands back the redirect's HTML body ('<a href="…">See Other</a>.'). Both callers
|
||||
# then took that page for the manifest: every publish prepended a stale redirect page and dropped
|
||||
# every prior image line, and --seal signed a page whose presigned URL expired 300 seconds later —
|
||||
# a signature over bytes that exist nowhere. Clients fetch WITH -L, so they checked the real
|
||||
# manifest against that signature and refused the feed, which from a Bazzite box is indistinguishable
|
||||
# from someone having tampered with it.
|
||||
#
|
||||
# The line filter is the second layer, and the one that does not depend on getting curl's flags
|
||||
# right: whatever the transport hands back, only well-formed "<sha256> <filename>" lines are ever
|
||||
# signed or re-published. It also scrubs a feed that already carries an injected page.
|
||||
read_manifest() {
|
||||
: > "$SUMS"; : > "$FETCHED"
|
||||
curl -fsSL "${AUTH[@]}" -o "$FETCHED" "$BASE/SHA256SUMS" || return 1
|
||||
grep -E '^[0-9a-f]{64} [^ ]+$' "$FETCHED" > "$SUMS" || :
|
||||
}
|
||||
|
||||
# sign_manifest — detached-sign $SUMS into $SIG with RPM_GPG_PRIVATE_KEY. Prints nothing and
|
||||
# returns 1 if no key is available; the caller decides whether that is survivable.
|
||||
@@ -88,14 +112,29 @@ require_signature() {
|
||||
|
||||
# --seal: re-sign whatever manifest the feed already has, no image, no pruning.
|
||||
if [ "$SEAL" = 1 ]; then
|
||||
curl -fsS "${AUTH[@]}" -o "$SUMS" "$BASE/SHA256SUMS" \
|
||||
|| { echo "no SHA256SUMS at $BASE — nothing to seal" >&2; exit 1; }
|
||||
sign_manifest || require_signature
|
||||
if [ -f "$SIG" ]; then
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
|
||||
echo "sealed $BASE ($(wc -l <"$SUMS") image(s))"
|
||||
read_manifest || { echo "no SHA256SUMS at $BASE — nothing to seal" >&2; exit 1; }
|
||||
# An empty result means the manifest was ALL junk. Signing that would hand clients a feed that
|
||||
# verifies and offers no images, which reads as "up to date" to `punktfunk-sysext update`.
|
||||
if [ ! -s "$SUMS" ]; then
|
||||
echo "$BASE/SHA256SUMS lists no images — refusing to seal it (feed needs a republish)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! sign_manifest; then
|
||||
require_signature # non-release: warn and leave the live feed exactly as it was
|
||||
exit 0
|
||||
fi
|
||||
# The signature must cover the bytes a client actually downloads, so a manifest that normalizing
|
||||
# changed gets re-published with it — otherwise the .asc would describe a file the registry does
|
||||
# not have, which is the very failure this is repairing. Manifest first, signature second, and
|
||||
# neither when the stored copy was already clean.
|
||||
if ! cmp -s "$SUMS" "$FETCHED"; then
|
||||
echo "normalizing $BASE/SHA256SUMS: $(grep -c '' <"$FETCHED") line(s) served, $(grep -c '' <"$SUMS") kept"
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
|
||||
fi
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
|
||||
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
|
||||
echo "sealed $BASE ($(grep -c '' <"$SUMS") image(s))"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -103,7 +142,14 @@ FNAME="$(basename "$RAW")"
|
||||
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
|
||||
|
||||
# Merge into the existing manifest: drop any prior line for this filename, append ours.
|
||||
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
|
||||
if read_manifest; then
|
||||
sed -i "\| $FNAME\$|d" "$SUMS"
|
||||
# Said out loud on purpose. A manifest that silently shrinks is how a feed loses its rollback
|
||||
# history, and that went unnoticed precisely because nothing ever reported the carry-over.
|
||||
echo "carrying forward $(grep -c '' <"$SUMS") image(s) from the existing manifest"
|
||||
else
|
||||
echo "no manifest at $BASE yet — starting a new feed"
|
||||
fi
|
||||
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
|
||||
|
||||
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
|
||||
|
||||
@@ -79,6 +79,16 @@ sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \
|
||||
# operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it.
|
||||
install -Dm0644 scripts/punktfunk-host-desktop-session.conf \
|
||||
"$STAGE/usr/share/punktfunk-host/punktfunk-host-desktop-session.conf"
|
||||
# Install-kind + channel marker, read by the host's update-check surface (planning:
|
||||
# host-update-from-web-console.md §4.1). ONE canonical path across all package formats —
|
||||
# /usr/share/punktfunk/, not this package's punktfunk-host/ data dir. A canary version
|
||||
# carries `~ciN`; anything else is stable.
|
||||
case "$VERSION" in
|
||||
*~ci*) _pf_update_channel=canary ;;
|
||||
*) _pf_update_channel=stable ;;
|
||||
esac
|
||||
printf 'apt %s\n' "$_pf_update_channel" | \
|
||||
install -Dm0644 /dev/stdin "$STAGE/usr/share/punktfunk/install-kind"
|
||||
# Optional headless KWin session unit (the kwin --virtual appliance), as the RPM/Arch ship.
|
||||
# Repoint its ExecStart from the dev source tree to the packaged script. NOT enabled by default.
|
||||
install -Dm0644 scripts/punktfunk-kde-session.service "$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service"
|
||||
|
||||
@@ -95,6 +95,19 @@ echo "==> configuring"
|
||||
# there would not start on the host. Pinning the fallback makes the outcome the same everywhere.
|
||||
# (gamescope's own meson.build hard-errors if libliftoff/vkroots are missing from this list, so
|
||||
# all three go together.)
|
||||
#
|
||||
# The C++ runtime goes STATIC for the same reason wlroots does: this binary is built on a ROLLING
|
||||
# distro and has to start on a FROZEN one. Arch's gcc (16.1.1 when this was written) makes the
|
||||
# compositor require `GLIBCXX_3.4.35`, and SteamOS 3.8.16 ships libstdc++ 3.4.34 — so the published
|
||||
# Arch package died on the very platform the gamescope backend matters most on ("version
|
||||
# GLIBCXX_3.4.35 not found"), while every other soname resolved and glibc was never close to the
|
||||
# limit (the binary asks for 2.38 at most; SteamOS has 2.41). Safe because
|
||||
# gamescope links NO shared C++ library (its `NEEDED` list is all C — glslang/SPIRV are build-time
|
||||
# only), so no C++ ABI ever crosses a shared boundary; it costs ~1 MB.
|
||||
# Appended to LDFLAGS rather than passed as `-Dcpp_link_args`, because that option would REPLACE
|
||||
# the value meson derives from the environment and silently drop makepkg's hardening flags
|
||||
# (`-z relro`, `-z now`, `--as-needed`).
|
||||
export LDFLAGS="${LDFLAGS:-} -static-libstdc++ -static-libgcc"
|
||||
meson setup "$BUILD" "$SRCDIR" \
|
||||
--prefix="$PREFIX" \
|
||||
--buildtype=release \
|
||||
@@ -112,6 +125,17 @@ ninja -C "$BUILD" ${JOBS:+-j "$JOBS"}
|
||||
# distro's gamescope package — and we need none of them: the host only ever execs the compositor.
|
||||
BIN="$BUILD/src/gamescope"
|
||||
[ -x "$BIN" ] || { echo "build produced no $BIN" >&2; exit 1; }
|
||||
# The static C++ runtime above is invisible in a successful build and only shows up as a binary
|
||||
# that will not start on an older distro — so assert it here, where a mistake is a build failure
|
||||
# instead of a package that dies at `--version` on SteamOS. No `libstdc++.so.6` in NEEDED is the
|
||||
# whole invariant (and it needs no version threshold to check).
|
||||
if command -v objdump >/dev/null; then
|
||||
objdump -p "$BIN" 2>/dev/null | grep -q 'NEEDED.*libstdc++' && {
|
||||
echo "built binary links libstdc++ dynamically — the static C++ runtime did not take, and this" >&2
|
||||
echo "package would not start on a distro older than the build host" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
DEST="${DESTDIR}${PREFIX}/bin/punktfunk-gamescope"
|
||||
echo "==> installing $DEST"
|
||||
install -Dm755 "$BIN" "$DEST"
|
||||
|
||||